Compare commits

...
Author SHA1 Message Date
4c4bb45761 fix(server): read Codex history in pages instead of one whole-thread reply
Opening a Codex agent whose timeline the daemon doesn't hold asked Codex
for the whole thread in one thread/read reply. Past the 128 MiB provider
line cap that reply killed the session; before the cap it crashed the
daemon. Codex 0.145 and newer reject that read on paginated threads, so
importing them failed.

Paginated threads now load through thread/items/list pages. A legacy
thread keeps its single read. A reply over the line cap fails only its own
request: for a legacy read, a warning row names the archive,
`codex migrate-rollouts`, unarchive steps; for a page, a warning row ends
the history there. Items are capped as they are projected, and rewind's
fork no longer returns the thread's turns.

Co-authored-by: hunterricks <hr@savagestrategic.io>
Co-authored-by: dundunHa <lixinpengde@gmail.com>
2026-10-29 10:56:25 +01:00
Mohamed Boudra ac6e4e9f96 Fix flaky 64 MiB image materialization test on Windows CI (#8198)
The responsiveness test timed wall-clock gaps between 1 ms ticks, which reached 50.35 ms on the shared Windows runner for the same code. It now measures main-thread CPU time between ticks and still fails when the payload decodes in one chunk.
2026-10-29 16:56:12 +07:00
paseo-bot[bot] c4b4516890 Keep split dividers under the pointer across mouse and gesture input (#6011)
* fix(app): keep split dividers under the mouse before Explorer is first opened

The main row only left the Explorer pane out once the Explorer had been
shown, because the split container read the registered pane id directly.
Before that, the hidden Explorer stayed in the row with its share of the
sizes, and every divider drag moved about 1.28 times the mouse distance.
Resolve the pane the same way the workspace screen does, so the default
Explorer pane is left out from the start.

Closes #5998

* fix(app): keep split dividers under gesture input

Measure the gesture from its initial press so activation does not discard the movement before its threshold. Cover coarse-pointer mouse and touch input alongside app-created workspaces, Explorer changes and reload.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-29 09:10:25 +00:00
Mohamed Boudra 75f58c4fe7 fix(release): upload only en-US screenshots to Google Play (#8195)
supply uploads every locale folder under fastlane/metadata/android in one
edit. Play rejects the F-Droid-only ja locale, which failed the whole edit in
the 0.14.0 release ("ja - Invalid request"), so no screenshots were replaced.
Stage only the en-US images, add a standalone workflow to rerun the upload for
the current release, and document the App Store associatedErrors gotcha.
2026-10-29 15:57:08 +07:00
paseo-ai[bot] fb69bf3314 fix: update lockfile signatures and Nix hash [skip ci] 2026-10-29 08:49:01 +00:00
Mohamed Boudra f7eef9dd0c chore(release): reconcile main with v0.14.0 (#8193)
Bring in what v0.14.0 shipped: the 0.14.0 entry in CHANGELOG.md and
CHANGELOG.zh-CN.md exactly as tagged (replacing the 0.14.0-beta.2 series
entry), the 0.14.0 version in every package.json and package-lock.json, and
the generated F-Droid changelogs. The unreleased notes on main stay above the
entry and now say they are not in 0.14.0.

Release commit: 8c6de842a3 (tag v0.14.0)
2026-10-29 15:40:29 +07:00
Mohamed Boudra 7d74ae5d6c docs(release): release from tags with selected cherry-picks while main stays open (#8188)
Replace the next-branch discipline with the main-first workflow: normal PRs
always merge into main, each release builds in a short-lived release workspace
from origin/main or a release tag plus approved cherry-picks, promotions take
an inventory that defaults to defer, and a reconcile PR brings the shipped
changelog, version and F-Droid files back into main.
2026-10-29 15:08:36 +07:00
babo-jeonguk 61841d1ed5 fix(app): stop web menus snapping back to their entering-animation rect (#8180)
Reanimated's web path saves a custom keyframe's element rect when the
animation ends, and writes it back as inline top/left/width/height when
it cleans the keyframe up 750ms after the start. A menu whose content
resized in between, such as a plugin popover whose data had just
arrived, was left clipped to its loading-state size or hanging over its
anchor.

Play the same keyframes through the Web Animations API on web, which
leaves no inline style, and drop the height release that only undid
part of that write. Native keeps the Reanimated Keyframe.
2026-10-29 07:58:09 +00:00
Mohamed Boudra 7c88a48be4 Publish store screenshots from the repo on each stable release (#8186)
* feat(release): publish store screenshots from the repo

Stable releases now upload the committed store renders: the App Store
review lane attaches the iPhone and iPad sets to the version it submits,
and a new Play lane replaces the phone and tablet screenshots after the
Android submit. F-Droid reads the same Android files, which now link to
the store renders instead of the website images.

The Play lane needs its own copy of the submit service-account key
(GOOGLE_PLAY_JSON_KEY in the EAS production environment) because the key
Expo stores for eas submit is not available to custom jobs, and EAS
Metadata does not handle screenshots for either store.

* fix(release): rewrite the Android screenshot links on every render

Google Play and F-Droid read numbered links to the Google renders. The
renderer now rewrites them from the panels it exported, so adding or
renaming a panel reaches all three stores.
2026-10-29 14:39:42 +07:00
paseo-bot[bot]andZeno_ 3196260bd4 Show completed edxeth Pi children as finished, live and after restart (#8176)
* fix(pi): settle edxeth subagent lifecycle events

* test(pi): preserve completed children when restoring captured histories

Verify the completed child and assistant transcript through history replay and the store consumed by clients. Both captured completion protocols otherwise leave a running descriptor.

Co-authored-by: Zeno_ <lin1071833138@gmail.com>

---------

Co-authored-by: Zeno_ <lin1071833138@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-29 07:06:46 +00:00
paseo-bot[bot] 870a44bc7f Deliver a steer message that arrives while the previous turn is still starting (#6192)
* Deliver a steer message that arrives while the previous turn is still starting

A steer send that reached an agent before its previous turn had a turn id
found no turn to steer and replaced the starting turn, so the earlier
message never reached the provider. Steering now waits for the starting
turn to be accepted (or fail) and steers the turn it became.

Fixes #6186

* Wake a waiting steer only once the starting turn has an id or its run has ended

A failed start used to wake the waiting steer before its run was cleared,
leaving the later send to replace a run that had already failed.

* Clear a failed start's run even when reporting the failure throws

A steer waiting on that start is released only when the run is cleared.

* Test that a failed start keeps a waiting steer until its run is cleared

* fix(server): steer Codex turns while native identification is pending

Wait for the accepted foreground turn to receive its native ID before steering it. Returning unavailable in this window replaced the turn even after the manager had waited for startup.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-29 05:55:50 +00:00
Mohamed Boudra e8201ab66b Make Claude session entrypoint relabeling optional (#8164)
* fix(claude): make session entrypoint relabeling opt-in

Keep inherited SDK and parent identities cleared. Honor the explicit session-open entrypoint contribution so users can opt into the label through a plugin instead of every session receiving it unconditionally.

* test(hub): use standard budget for restart and worktree recovery

The Windows CI scenario timed out at its custom 20-second limit while the other Hub cases took 4–10 seconds each. This scenario starts two daemons and creates, archives and restores a worktree. Use the existing 30-second suite budget without changing any assertions.
2026-10-29 12:45:41 +07:00
76 changed files with 2937 additions and 409 deletions
+1 -1
View File
@@ -3,7 +3,7 @@ name: CI
on:
push:
branches: [main]
# Every base branch, so stacked PRs and PRs into `next` get the same checks.
# Every base branch, so stacked PRs and PRs into any base get the same checks.
pull_request:
merge_group:
workflow_dispatch:
+4 -4
View File
@@ -1,6 +1,6 @@
# Changelog
Not released yet. These changes are on `main` but not in 0.14.0-beta.2; the next release entry includes them.
Not released yet. These changes are on `main` but not in 0.14.0; the next release entry includes them.
Added:
@@ -11,7 +11,7 @@ Improved:
- Added `server.addHook` with `subject.operation.position` names and the awaited `worktree.create.after` hook before setup and agents; deprecated the previous plugin hook registration methods without changing their behavior ([#8081](https://github.com/getpaseo/paseo/pull/8081))
## 0.14.0-beta.2 - 2026-10-28
## 0.14.0 - 2026-10-29
Plugins can now add tools to individual agents and configure their MCP servers when sessions open.
@@ -43,7 +43,6 @@ 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
@@ -72,7 +71,8 @@ Voice settings now let you configure speech providers and download local models
- 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 the beta
- 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))
## 0.13.2 - 2026-10-26
+4 -4
View File
@@ -1,6 +1,6 @@
# 更新日志
尚未发布。以下变更已合入 `main`,但不包含在 0.14.0-beta.2 中;下一个版本的条目会包含它们。
尚未发布。以下变更已合入 `main`,但不包含在 0.14.0 中;下一个版本的条目会包含它们。
新增:
@@ -11,7 +11,7 @@
- 新增采用 `subject.operation.position` 命名的 `server.addHook` 和在 setup 及 Agent 之前等待的 `worktree.create.after` 钩子,弃用原有的插件钩子注册方法并保留原有行为 ([#8081](https://github.com/getpaseo/paseo/pull/8081))
## 0.14.0-beta.2 - 2026-10-28
## 0.14.0 - 2026-10-29
插件现在可以为单个 Agent 添加工具,并在会话打开时配置其 MCP 服务器。
@@ -43,7 +43,6 @@
- 减少打开和分页加载大型 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))
### 修复
@@ -72,7 +71,8 @@
- 语音模式默认关闭,可在语音设置中启用
- 如果通过环境变量选择 OpenAI 语音,请将仅保存在旧版 `providers.openai` 中的密钥和服务地址移至语音设置
- 降级至 0.13.2 前,请恢复备份的 `config.json` 或转换 beta 保存的语音凭据和空闲清理设置
- 降级至 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 - 2026-10-26
+3 -4
View File
@@ -117,10 +117,9 @@ See [docs/development.md](docs/development.md) for full setup, build sync requir
## Release branches
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.
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).
## Critical rules
+4
View File
@@ -95,4 +95,8 @@ Callout crops are in native capture pixels. To find them, overlay a 100 px grid
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).
+19 -2
View File
@@ -9,7 +9,7 @@
// elsewhere point SF_FONT_DIR at a folder holding SFNS.ttf copied from a Mac.
import { existsSync } from "node:fs";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
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";
@@ -23,6 +23,7 @@ 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.
@@ -116,6 +117,7 @@ const STORES = {
},
google: {
device: "iphone",
androidScreenshots: "phoneScreenshots",
width: 1440,
height: 2560,
textTop: 150,
@@ -134,6 +136,7 @@ const STORES = {
},
"google-tablet": {
device: "ipad",
androidScreenshots: "tenInchScreenshots",
width: 1800,
height: 3200,
textTop: 200,
@@ -352,6 +355,18 @@ async function expectLines(view, selector, expected, label) {
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;
@@ -387,7 +402,9 @@ try {
const marks = await loadAgentMarks(work);
const rows = [];
for (const [name, store] of Object.entries(STORES)) {
rows.push(await renderStore(browser, name, store, font, marks, work));
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 {
+4 -1
View File
@@ -215,7 +215,8 @@ fastlane/metadata/android/
│ ├── images/
│ │ ├── icon.png (512x512)
│ │ ├── featureGraphic.png (1024x500)
│ │ └── phoneScreenshots/ (1.png, 2.png, ...)
│ │ ├── phoneScreenshots/ (1.png, 2.png, ... linked to the store renders)
│ │ └── tenInchScreenshots/ (1.png, 2.png, ... linked to the store renders)
│ └── changelogs/ (generated — see below)
├── ja/
└── zh-CN/
@@ -223,6 +224,8 @@ 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.
+9 -7
View File
@@ -200,13 +200,15 @@ its own.
## Gotchas
- **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.
- **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.
- **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.
- **The sheet's content is teleported out of the menu's subtree**, so `MenuSheetSurface` rebuilds
+147 -74
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** (local, reversible — agent does this):
**Preparation** (reversible — agent does this):
- format, lint, typecheck all green
- resolve the release source to one commit and confirm that commit's existing CI is 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
- 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,9 +17,10 @@ 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
- run the release, which publishes npm and pushes the prepared branch and tag
- commit the approved release inputs locally on the release branch
- run the release, which publishes npm and pushes the release 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:
@@ -27,56 +28,128 @@ 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.
## Release source and CI
## Main stays open
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`.
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.
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`.
## Choosing a path
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.
The user decides when the changes on `main` warrant a release and how much risk it takes:
## Release branch discipline
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`.
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.
A beta follows these defaults, which the user can explicitly override:
- 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.
- 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.
**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.
npm dist-tags are independent pointers:
### Hotfix from a release tag
- 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.
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.
## 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`.
## ACP catalog updates
@@ -91,21 +164,6 @@ 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
@@ -133,7 +191,7 @@ as described in **Fixing a failed release build**.
Before running any stable release command:
- 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.
- Make sure the release source passed CI, the approved release inputs are committed locally on the release 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.
@@ -145,9 +203,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, move npm's `beta` pointer to the new stable
version for every published package. This changes dist-tags only; do not
republish the packages:
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:
```bash
PASEO_VERSION=$(node -p "require('./package.json').version")
@@ -156,8 +214,8 @@ for package in highlight relay protocol client plugin server cli; do
done
```
Verify both npm tags now resolve to `PASEO_VERSION` before considering the
stable release complete.
Verify `latest` resolves to `PASEO_VERSION`, and `beta` resolves to `PASEO_VERSION` or the
active newer beta, 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`.
@@ -175,7 +233,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 using the command above.
# Then move npm's beta dist-tag to this stable version when it is newer than beta (see above).
```
## Beta flow
@@ -305,8 +363,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, 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 (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 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.
@@ -317,6 +375,17 @@ 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/`:
@@ -359,6 +428,7 @@ 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.
@@ -477,7 +547,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 actual code on `main` with the fix included.
This ensures the checkout ref matches the code you want built, 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
@@ -620,7 +690,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 --format='%H %s' v<previous>..<release-source-sha> | grep -E '\(#[0-9]+\)$'` pulls the PR number out of squash commit subjects.
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.
## Changelog ordering
@@ -652,11 +722,11 @@ Every entry covers `previous stable release → release source`: first beta, lat
### Beta release
- [ ] The resolved release source is the intended commit (default `origin/main`) and its existing CI is green
- [ ] 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
- [ ] 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
- [ ] Release preparation stayed local until the approved release command pushed the complete branch and tag
- [ ] The changelog, ACP, lockfile, and version commits stayed local until the approved release command pushed the release 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`
@@ -667,22 +737,24 @@ 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 resolved release source is the intended commit (default `origin/main`) and its existing CI is green
- [ ] 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
- [ ] 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
- [ ] Release preparation stayed local until the approved release command pushed the complete branch and tag
- [ ] The changelog, ACP, lockfile, and version commits stayed local until the approved release command pushed the release 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
- [ ] Move npm's `beta` dist-tag to the new stable version for every published package and verify both `latest` and `beta` resolve to it
- [ ] `latest` resolves to the new stable, and `beta` resolves to it too unless an active newer beta holds `beta`
- [ ] 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`
@@ -696,4 +768,5 @@ 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
@@ -0,0 +1,10 @@
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
@@ -0,0 +1,10 @@
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
@@ -0,0 +1,10 @@
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
@@ -0,0 +1,10 @@
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 @@
../../../../../../packages/website/public/phone-1.png
../../../../../../assets/store-screenshots/output/google/01-sidebar.png
@@ -1 +1 @@
../../../../../../packages/website/public/phone-2.png
../../../../../../assets/store-screenshots/output/google/02-chat.png
@@ -1 +1 @@
../../../../../../packages/website/public/phone-3.png
../../../../../../assets/store-screenshots/output/google/03-plugins.png
@@ -0,0 +1 @@
../../../../../../assets/store-screenshots/output/google/04-review.png
@@ -0,0 +1 @@
../../../../../../assets/store-screenshots/output/google/05-terminal.png
@@ -0,0 +1 @@
../../../../../../assets/store-screenshots/output/google-tablet/01-sidebar.png
@@ -0,0 +1 @@
../../../../../../assets/store-screenshots/output/google-tablet/02-chat.png
@@ -0,0 +1 @@
../../../../../../assets/store-screenshots/output/google-tablet/03-plugins.png
@@ -0,0 +1 @@
../../../../../../assets/store-screenshots/output/google-tablet/04-review.png
@@ -0,0 +1 @@
../../../../../../assets/store-screenshots/output/google-tablet/05-terminal.png
+1 -1
View File
@@ -1 +1 @@
sha256-AmMp/4ugSUKXTL6H0oJaGJMztVGiYCPXeWteQaaaguw=
sha256-6C4Rjh0mRVrC9akBVGK0NZaCaLJhwKObT7HrgPmcvyw=
+28 -28
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.14.0-beta.2",
"version": "0.14.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.14.0-beta.2",
"version": "0.14.0",
"hasInstallScript": true,
"license": "Apache-2.0",
"workspaces": [
@@ -37590,7 +37590,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.14.0-beta.2",
"version": "0.14.0",
"dependencies": {
"@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4",
@@ -39793,12 +39793,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.14.0-beta.2",
"version": "0.14.0",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.14.0-beta.2",
"@getpaseo/protocol": "0.14.0-beta.2",
"@getpaseo/server": "0.14.0-beta.2",
"@getpaseo/client": "0.14.0",
"@getpaseo/protocol": "0.14.0",
"@getpaseo/server": "0.14.0",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -40045,10 +40045,10 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.14.0-beta.2",
"version": "0.14.0",
"dependencies": {
"@getpaseo/protocol": "0.14.0-beta.2",
"@getpaseo/relay": "0.14.0-beta.2",
"@getpaseo/protocol": "0.14.0",
"@getpaseo/relay": "0.14.0",
"ws": "^8.20.0",
"zod": "^4.4.3"
},
@@ -40060,7 +40060,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.14.0-beta.2",
"version": "0.14.0",
"license": "Apache-2.0",
"dependencies": {
"@getpaseo/cli": "*",
@@ -40304,7 +40304,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.14.0-beta.2",
"version": "0.14.0",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -40350,7 +40350,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.14.0-beta.2",
"version": "0.14.0",
"dependencies": {
"@codemirror/language": "6.12.4",
"@codemirror/legacy-modes": "^6.5.3",
@@ -40379,15 +40379,15 @@
},
"packages/plugin": {
"name": "@getpaseo/plugin",
"version": "0.14.0-beta.2",
"version": "0.14.0",
"dependencies": {
"@agentclientprotocol/sdk": "^1.4.0",
"i18next": "^26.3.0",
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.14.0-beta.2",
"@getpaseo/protocol": "0.14.0-beta.2",
"@getpaseo/client": "0.14.0",
"@getpaseo/protocol": "0.14.0",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.2.3",
@@ -40397,8 +40397,8 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.14.0-beta.2",
"@getpaseo/protocol": "0.14.0-beta.2",
"@getpaseo/client": "0.14.0",
"@getpaseo/protocol": "0.14.0",
"react": ">=19.1.0 <20",
"react-native": ">=0.81.5",
"zod": "^4.4.3"
@@ -40420,7 +40420,7 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.14.0-beta.2",
"version": "0.14.0",
"dependencies": {
"semver": "^7.8.5",
"zod": "^4.4.3"
@@ -40447,7 +40447,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.14.0-beta.2",
"version": "0.14.0",
"dependencies": {
"@noble/ciphers": "^2.4.0",
"@noble/curves": "^2.4.0",
@@ -40668,16 +40668,16 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.14.0-beta.2",
"version": "0.14.0",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.14.0-beta.2",
"@getpaseo/highlight": "0.14.0-beta.2",
"@getpaseo/plugin": "0.14.0-beta.2",
"@getpaseo/protocol": "0.14.0-beta.2",
"@getpaseo/relay": "0.14.0-beta.2",
"@getpaseo/client": "0.14.0",
"@getpaseo/highlight": "0.14.0",
"@getpaseo/plugin": "0.14.0",
"@getpaseo/protocol": "0.14.0",
"@getpaseo/relay": "0.14.0",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
@@ -41598,7 +41598,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.14.0-beta.2",
"version": "0.14.0",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
@@ -43208,7 +43208,7 @@
},
"plugins": {
"name": "@getpaseo/builtin-plugins",
"version": "0.14.0-beta.2",
"version": "0.14.0",
"dependencies": {
"@getpaseo/plugin": "*",
"zod": "^4.4.3"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.14.0-beta.2",
"version": "0.14.0",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
@@ -42,6 +42,20 @@ 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]
@@ -0,0 +1,27 @@
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
@@ -0,0 +1,134 @@
import { expect, type Locator, type Page } from "@playwright/test";
import { test } from "../support/fixtures";
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
import { createCreationScenario } from "../support/helpers/creation";
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
import { seedWorkspace } from "../support/helpers/seed-client";
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
const DRAG_DISTANCE = -40;
const TOLERANCE_PX = 2;
function splitDividers(page: Page): Locator {
return page.getByTestId("workspace-split-resize-handle").filter({ visible: true });
}
async function dividerX(divider: Locator): Promise<number> {
const box = await divider.boundingBox();
if (!box) throw new Error("Divider has no bounding box");
return box.x;
}
async function dragDivider(divider: Locator, deltaX: number): Promise<void> {
const page = divider.page();
const box = await divider.boundingBox();
if (!box) throw new Error("Divider has no bounding box");
const x = box.x + box.width / 2;
const y = box.y + box.height / 2;
await page.mouse.move(x, y);
await page.mouse.down();
await page.mouse.move(x + deltaX, y, { steps: 8 });
await page.mouse.up();
}
async function dragDividerWithTouch(divider: Locator, deltaX: number): Promise<void> {
const page = divider.page();
const box = await divider.boundingBox();
if (!box) throw new Error("Divider has no bounding box");
const x = box.x + box.width / 2;
const y = box.y + box.height / 2;
const input = await page.context().newCDPSession(page);
try {
await input.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ x, y }] });
for (let step = 1; step <= 8; step++) {
await input.send("Input.dispatchTouchEvent", {
type: "touchMove",
touchPoints: [{ x: x + (deltaX * step) / 8, y }],
});
}
await input.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] });
} finally {
await input.detach();
}
}
async function splitIntoFourPanes(page: Page): Promise<void> {
for (const expectedDividers of [1, 2, 3]) {
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
await expect(splitDividers(page)).toHaveCount(expectedDividers);
}
}
async function expectDividersTrackPointer(
page: Page,
distance = DRAG_DISTANCE,
drag = dragDivider,
): Promise<void> {
for (const index of [0, 1, 2]) {
const divider = splitDividers(page).nth(index);
const before = await dividerX(divider);
await drag(divider, distance);
await expect
.poll(async () => Math.abs((await dividerX(divider)) - before - distance), {
message: `divider ${index + 1} should stay under the pointer`,
})
.toBeLessThanOrEqual(TOLERANCE_PX);
}
}
for (const hasTouch of [false, true]) {
test.describe(`Split pane drag tracking (${hasTouch ? "coarse" : "fine"} pointer)`, () => {
test.use({ hasTouch });
test("a divider moves as far as the pointer in the main row", async ({ page }) => {
await page.setViewportSize({ width: 1600, height: 900 });
const workspace = await seedWorkspace({ repoPrefix: "split-drag-tracking-" });
try {
await gotoWorkspace(page, workspace.workspaceId);
await waitForWorkspaceTabsVisible(page);
await clickNewChat(page);
await splitIntoFourPanes(page);
await expectDividersTrackPointer(
page,
DRAG_DISTANCE,
hasTouch ? dragDividerWithTouch : dragDivider,
);
} finally {
await workspace.cleanup();
}
});
test("dividers track the pointer after app creation, Explorer changes and reload", async ({
page,
}) => {
await page.setViewportSize({ width: 1600, height: 900 });
const creation = await createCreationScenario(page);
try {
await creation.openWorkspaceForm("local");
await creation.submitPrompt("Reply briefly.", "Create");
await creation.expectAssistantReply();
await waitForWorkspaceTabsVisible(page);
await splitIntoFourPanes(page);
await expectDividersTrackPointer(page);
await page
.getByTestId("workspace-explorer-toggle")
.filter({ visible: true })
.first()
.click();
await expect(page.getByTestId("workspace-explorer-sidebar")).toBeVisible();
await expectDividersTrackPointer(page, -DRAG_DISTANCE);
await page
.getByTestId("workspace-explorer-toggle")
.filter({ visible: true })
.first()
.click();
await expect(page.getByTestId("workspace-explorer-sidebar")).not.toBeVisible();
await page.reload();
await expect(splitDividers(page)).toHaveCount(3);
await expectDividersTrackPointer(page);
} finally {
await creation.cleanup();
}
});
});
}
+64 -1
View File
@@ -1,7 +1,47 @@
require "fileutils"
require "tmpdir"
default_platform(:ios)
APP_IDENTIFIER = "sh.paseo"
# Store screenshots are rendered into assets/store-screenshots/output; see the
# README there. Google Play and F-Droid both read fastlane/metadata/android at
# the repo root, whose screenshot files link to those renders.
REPO_ROOT = File.expand_path("../../..", __dir__)
STORE_SCREENSHOTS = File.join(REPO_ROOT, "assets/store-screenshots/output")
ANDROID_METADATA = File.join(REPO_ROOT, "fastlane/metadata/android")
# deliver reads screenshots from one folder per language and picks the device
# slot from each image's size, so both Apple sets go into en-US together.
def stage_app_store_screenshots
root = Dir.mktmpdir("app-store-screenshots")
locale = File.join(root, "en-US")
FileUtils.mkdir_p(locale)
{ "apple" => "iphone", "apple-ipad" => "ipad" }.each do |set, device|
Dir.glob(File.join(STORE_SCREENSHOTS, set, "*.png")).sort.each do |file|
FileUtils.cp(file, File.join(locale, "#{device}-#{File.basename(file)}"))
end
end
root
end
# Google Play only has en-US screenshots, and some F-Droid locales in
# ANDROID_METADATA are not Play languages (Play rejects "ja"). supply uploads
# every locale folder in one edit, so give it only the en-US images.
def stage_google_play_screenshots
root = Dir.mktmpdir("google-play-screenshots")
images = File.join(ANDROID_METADATA, "en-US/images")
%w[phoneScreenshots tenInchScreenshots].each do |kind|
target = File.join(root, "en-US/images", kind)
FileUtils.mkdir_p(target)
Dir.glob(File.join(images, kind, "*.png")).sort.each do |file|
FileUtils.cp(File.realpath(file), File.join(target, File.basename(file)))
end
end
root
end
platform :ios do
desc "Submit the latest TestFlight build to App Store Review"
lane :submit_review do
@@ -87,7 +127,8 @@ platform :ios do
},
force: true,
skip_binary_upload: true,
skip_screenshots: true,
screenshots_path: stage_app_store_screenshots,
overwrite_screenshots: true,
precheck_include_in_app_purchases: false,
submission_information: {
add_id_info_uses_idfa: false,
@@ -96,3 +137,25 @@ platform :ios do
)
end
end
platform :android do
desc "Replace the Google Play store screenshots with the ones in the repo"
lane :upload_screenshots do
# supply attaches listing changes to a release on the production track.
# The release workflow passes the version code it just submitted; without
# it, supply uses the track's only release.
version_code = ENV["ANDROID_VERSION_CODE"].to_s.strip
supply(
package_name: APP_IDENTIFIER,
json_key: ENV.fetch("GOOGLE_PLAY_JSON_KEY"),
metadata_path: stage_google_play_screenshots,
version_code: version_code.empty? ? nil : version_code.to_i,
skip_upload_apk: true,
skip_upload_aab: true,
skip_upload_metadata: true,
skip_upload_changelogs: true,
skip_upload_images: true,
sync_image_upload: true,
)
end
end
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.14.0-beta.2",
"version": "0.14.0",
"private": true,
"main": "index.ts",
"scripts": {
+21 -14
View File
@@ -49,7 +49,7 @@ export function ResizeHandle({
const { theme } = useUnistyles();
const finePointer = useHasFinePointer();
const pointerStatesRef = useRef(new Map<number, PointerState>());
const touchDragRef = useRef<ResizeHandleDrag | null>(null);
const touchStateRef = useRef<PointerState | null>(null);
const cursorBeforeDragRef = useRef<string | null>(null);
const hoverTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [active, setActive] = useState(false);
@@ -151,23 +151,30 @@ export function ResizeHandle({
const touchGesture = useMemo(() => {
const gesture = Gesture.Pan()
.runOnJS(true)
.onBegin(() => setDragging(true))
.onStart(() => {
touchDragRef.current = startResizeHandleDrag({
sizes,
index,
preview: (nextSizes) => onPreviewResizeSplit(groupId, nextSizes),
commit: (nextSizes) => onResizeSplit(groupId, nextSizes),
});
.onBegin((event) => {
setDragging(true);
touchStateRef.current = {
containerSize,
pointerStart: direction === "horizontal" ? event.absoluteX : event.absoluteY,
drag: startResizeHandleDrag({
sizes,
index,
preview: (nextSizes) => onPreviewResizeSplit(groupId, nextSizes),
commit: (nextSizes) => onResizeSplit(groupId, nextSizes),
}),
};
})
.onUpdate((event) => {
if (containerSize <= 0) return;
const translation = direction === "horizontal" ? event.translationX : event.translationY;
touchDragRef.current?.move(translation / containerSize);
const state = touchStateRef.current;
if (!state || state.containerSize <= 0) return;
// Gesture Handler resets web translation when the pan activates. Measure from
// the initial press so the activation distance is included on every platform.
const pointerCurrent = direction === "horizontal" ? event.absoluteX : event.absoluteY;
state.drag.move((pointerCurrent - state.pointerStart) / state.containerSize);
})
.onEnd(() => touchDragRef.current?.finish())
.onEnd(() => touchStateRef.current?.drag.finish())
.onFinalize(() => {
touchDragRef.current = null;
touchStateRef.current = null;
setDragging(false);
});
@@ -48,6 +48,7 @@ import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-
import {
collectAllPanes,
createDefaultLayout,
selectExplorerSidebarPaneId,
useWorkspaceLayoutStore,
type SplitNode,
type SplitPane,
@@ -183,8 +184,8 @@ export function SplitContainer({
} | null>(null);
const maximizedPaneId =
maximizedPane?.workspaceKey === workspaceKey ? maximizedPane.paneId : null;
const explorerSidebarPaneId = useWorkspaceLayoutStore(
(state) => state.explorerSidebarPaneIdByWorkspace[workspaceKey] ?? null,
const explorerSidebarPaneId = useWorkspaceLayoutStore((state) =>
selectExplorerSidebarPaneId(state, workspaceKey),
);
const mainRoot = useMemo(
@@ -0,0 +1,124 @@
import React, { act, useEffect, useState, type ReactNode } from "react";
import { createRoot, type Root } from "react-dom/client";
import { Text, View } from "react-native";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { page } from "vitest/browser";
import { MenuRoot, MenuSurface, MenuTrigger, type Size } from "./index";
// App sources compile against the classic JSX runtime, which expects React on the global.
beforeEach(() => vi.stubGlobal("React", React));
/**
* A real browser, because the regression lives in Reanimated's web entering path: it snapshots
* the surface's rect when a custom keyframe ends and writes that rect back as inline
* top/left/width/height when it cleans the keyframe up, 750ms after the animation starts.
* Anything that moved or resized the surface in between is undone by that write.
*/
interface Mounted {
root: Root;
container: HTMLDivElement;
}
const mounted: Mounted[] = [];
function mount(node: ReactNode): void {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
act(() => root.render(node));
mounted.push({ root, container });
}
afterEach(() => {
for (const entry of mounted.splice(0)) {
act(() => entry.root.unmount());
entry.container.remove();
}
});
const OFFSET = 12;
const PILL_POSITION = { position: "absolute", top: 700, left: 40 } as const;
const SHORT: Size = { width: 200, height: 40 };
const TALL: Size = { width: 200, height: 240 };
const WIDE: Size = { width: 480, height: 40 };
/** Inside the window between the entering animation ending and Reanimated's cleanup. */
const GROWS_BEFORE_CLEANUP_MS = 400;
/** Past Reanimated's cleanup, where the surface used to keep a fixed height. */
const GROWS_AFTER_CLEANUP_MS = 1_000;
/** Long enough after the content grew for anything that would write to the surface to land. */
const SETTLE_MS = 1_200;
/** Plain timers, not `act`: an async `act` holds every update until it resolves. */
function wait(ms: number): Promise<void> {
return new Promise<void>((resolve) => setTimeout(resolve, ms));
}
function element(testID: string): HTMLElement {
const found = document.querySelector(`[data-testid="${testID}"]`);
if (!(found instanceof HTMLElement)) throw new Error(`${testID} did not render`);
return found;
}
/** Stands in for a popover whose data lands after it opened, like a plugin's first RPC. */
function GrowsAfterOpen({ afterMs, from, to }: { afterMs: number; from: Size; to: Size }) {
const [grown, setGrown] = useState(false);
useEffect(() => {
const timer = setTimeout(() => setGrown(true), afterMs);
return () => clearTimeout(timer);
}, [afterMs]);
return <View testID="body" style={grown ? to : from} />;
}
function mountMenuAbovePill(body: ReactNode): void {
mount(
<View style={PILL_POSITION}>
<MenuRoot>
<MenuTrigger testID="pill" accessibilityLabel="Open panel">
<Text>Open panel</Text>
</MenuTrigger>
<MenuSurface
side="top"
align="start"
offset={OFFSET}
minWidth={280}
maxWidth={620}
testID="panel"
>
{body}
</MenuSurface>
</MenuRoot>
</View>,
);
act(() => element("pill").click());
}
describe("anchored menu surface on web", () => {
beforeEach(async () => {
await page.viewport(1280, 900);
});
it.each([GROWS_BEFORE_CLEANUP_MS, GROWS_AFTER_CLEANUP_MS])(
"stays on its anchor when its content grows %ims after opening",
async (afterMs) => {
mountMenuAbovePill(<GrowsAfterOpen afterMs={afterMs} from={SHORT} to={TALL} />);
await wait(afterMs + SETTLE_MS);
const pill = element("pill").getBoundingClientRect();
const panel = element("panel").getBoundingClientRect();
expect(panel.height).toBeGreaterThan(240);
expect(panel.bottom).toBeLessThanOrEqual(pill.top);
},
);
it("widens when its content widens after the entering animation", async () => {
mountMenuAbovePill(<GrowsAfterOpen afterMs={GROWS_BEFORE_CLEANUP_MS} from={SHORT} to={WIDE} />);
await wait(GROWS_BEFORE_CLEANUP_MS + SETTLE_MS);
const body = element("body").getBoundingClientRect();
const panel = element("panel").getBoundingClientRect();
expect(panel.width).toBeGreaterThanOrEqual(body.width);
});
});
@@ -2,6 +2,7 @@ import {
useCallback,
useEffect,
useId,
useLayoutEffect,
useMemo,
useState,
type ReactElement,
@@ -47,56 +48,41 @@ const contentEntering = new Keyframe({
100: { opacity: 1, transform: [{ scale: 1 }] },
}).duration(CONTENT_ENTERING_DURATION_MS);
/** `contentEntering` for the browser's own animation engine. Both default to linear easing. */
const WEB_CONTENT_ENTERING_KEYFRAMES: PropertyIndexedKeyframes = {
opacity: [0, 1],
transform: ["scale(0.97)", "scale(1)"],
};
/** Web plays the same keyframes itself; see `useWebContentEntering`. */
const nativeContentEntering = isWeb ? undefined : contentEntering;
const contentExiting = new Keyframe({
0: { opacity: 1, transform: [{ scale: 1 }] },
100: { opacity: 0, transform: [{ scale: 0.97 }] },
}).duration(100);
function releaseFixedMenuHeight(surfaceNativeID: string): void {
if (!isWeb) return;
document.getElementById(surfaceNativeID)?.style.removeProperty("height");
}
/**
* Reanimated's web entering animation leaves an inline height snapshot on the measured surface.
* Once the menu is open, height must return to content-sized so rows can grow in place — and so
* a submenu page taller than the page it replaced is not clipped to the old height.
*
* `revision` is what makes that second case work: bump it whenever the rendered content changes
* identity (a page push, for instance) and the snapshot is released again.
* On web the surface enters through the Web Animations API rather than Reanimated. Reanimated's
* web path snapshots an element's rect when a custom keyframe ends and, when it cleans the keyframe
* up 750ms after the start, writes that rect back as inline top/left/width/height. A surface that
* moved or resized in between — content that grew once its data arrived — snapped back to the old
* rect and stayed there, clipped or hanging past its anchor. A Web Animation leaves no inline style.
*/
function useReleaseFixedMenuHeight({
contentSize,
enabled,
function useWebContentEntering({
placed,
surfaceNativeID,
revision,
}: {
contentSize: Size | null;
enabled: boolean;
placed: boolean;
surfaceNativeID: string;
revision?: string | number;
}): void {
useEffect(() => {
if (!enabled) return undefined;
const release = () => {
releaseFixedMenuHeight(surfaceNativeID);
if (typeof requestAnimationFrame === "function") {
requestAnimationFrame(() => releaseFixedMenuHeight(surfaceNativeID));
}
};
const timers: ReturnType<typeof setTimeout>[] = [
setTimeout(release, CONTENT_ENTERING_DURATION_MS),
];
if (contentSize) {
timers.push(setTimeout(release, 0));
}
return () => {
for (const timer of timers) clearTimeout(timer);
};
}, [contentSize, enabled, surfaceNativeID, revision]);
useLayoutEffect(() => {
if (!isWeb || !placed || typeof document === "undefined") return undefined;
const animation = document
.getElementById(surfaceNativeID)
?.animate(WEB_CONTENT_ENTERING_KEYFRAMES, CONTENT_ENTERING_DURATION_MS);
return () => animation?.cancel();
}, [placed, surfaceNativeID]);
}
/**
@@ -224,8 +210,6 @@ export interface AnchoredSurfaceProps {
fullWidth?: boolean;
horizontalPadding?: number;
scrollable?: boolean;
/** Bump when the rendered content changes identity, so the height snapshot is released. */
revision?: string | number;
/** A submenu sits inside its parent's overlay and must not paint a second backdrop. */
backdrop?: boolean;
/**
@@ -259,7 +243,6 @@ export function AnchoredSurface({
fullWidth = false,
horizontalPadding = 16,
scrollable = false,
revision,
backdrop = true,
onPointerEnter,
onPointerLeave,
@@ -269,33 +252,25 @@ export function AnchoredSurface({
}: AnchoredSurfaceProps): ReactElement | null {
const { t } = useTranslation();
const surfaceNativeID = useId();
const { position, actualPlacement, contentSize, visibleContentSize, onContentLayout } =
useAnchoredPosition({
open,
anchorRect,
anchorRef,
side,
align,
offset,
scrollable,
maxHeight,
});
useReleaseFixedMenuHeight({
contentSize,
enabled: open,
surfaceNativeID,
revision,
const { position, actualPlacement, visibleContentSize, onContentLayout } = useAnchoredPosition({
open,
anchorRect,
anchorRef,
side,
align,
offset,
scrollable,
maxHeight,
});
// The surface is placed once both measurements land. Until then it sits off-screen so it can
// be measured, and it must not animate: Reanimated's web entering path snapshots the element's
// rect when the animation ends and writes that rect back as inline top/left when it cleans the
// keyframe up 750ms later. If the animation ends before placement, the snapshot is the
// off-screen rect and the menu jumps back off-screen. Remounting on placement starts the
// animation from the final position.
// be measured, and it must not animate: an entering animation that ran there would be over
// before anyone saw the menu. Remounting on placement starts the native animation from the
// final position; web starts its own animation on placement.
const placed = position !== null;
useWebContentEntering({ placed, surfaceNativeID });
useEffect(() => {
if (!isWeb || !open || !placed || typeof document === "undefined") return undefined;
const frame = requestAnimationFrame(() => {
@@ -383,7 +358,7 @@ export function AnchoredSurface({
dataSet={surfaceDataSet}
style={styles.content}
frameStyle={frameStyle}
entering={placed ? contentEntering : undefined}
entering={placed ? nativeContentEntering : undefined}
exiting={
!placed || isWeb || !onExited
? undefined
@@ -344,7 +344,6 @@ function MenuFlyout({
maxHeight={maxHeight}
scrollable={scrollable}
backdrop={false}
revision={page.id}
onPointerEnter={cancelHoverClose}
onPointerLeave={handleHoverOut}
testID={testID}
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.14.0-beta.2",
"version": "0.14.0",
"description": "Paseo CLI - control your AI coding agents from the command line",
"bin": {
"paseo": "bin/paseo"
@@ -28,9 +28,9 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.14.0-beta.2",
"@getpaseo/protocol": "0.14.0-beta.2",
"@getpaseo/server": "0.14.0-beta.2",
"@getpaseo/client": "0.14.0",
"@getpaseo/protocol": "0.14.0",
"@getpaseo/server": "0.14.0",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/client",
"version": "0.14.0-beta.2",
"version": "0.14.0",
"description": "Paseo client SDK package",
"files": [
"dist",
@@ -47,8 +47,8 @@
"test": "vitest run"
},
"dependencies": {
"@getpaseo/protocol": "0.14.0-beta.2",
"@getpaseo/relay": "0.14.0-beta.2",
"@getpaseo/protocol": "0.14.0",
"@getpaseo/relay": "0.14.0",
"ws": "^8.20.0",
"zod": "^4.4.3"
},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.14.0-beta.2",
"version": "0.14.0",
"private": true,
"description": "Paseo desktop app (Electron wrapper)",
"homepage": "https://paseo.sh",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/expo-two-way-audio",
"version": "0.14.0-beta.2",
"version": "0.14.0",
"description": "Native module for two way audio streaming",
"keywords": [
"ExpoTwoWayAudio",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.14.0-beta.2",
"version": "0.14.0",
"files": [
"dist",
"!dist/**/*.map",
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/plugin",
"version": "0.14.0-beta.2",
"version": "0.14.0",
"description": "Paseo plugin SDK package",
"files": [
"dist",
@@ -77,8 +77,8 @@
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.14.0-beta.2",
"@getpaseo/protocol": "0.14.0-beta.2",
"@getpaseo/client": "0.14.0",
"@getpaseo/protocol": "0.14.0",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.2.3",
@@ -88,8 +88,8 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.14.0-beta.2",
"@getpaseo/protocol": "0.14.0-beta.2",
"@getpaseo/client": "0.14.0",
"@getpaseo/protocol": "0.14.0",
"react": ">=19.1.0 <20",
"react-native": ">=0.81.5",
"zod": "^4.4.3"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/protocol",
"version": "0.14.0-beta.2",
"version": "0.14.0",
"description": "Paseo shared protocol schemas and wire types",
"files": [
"dist",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.14.0-beta.2",
"version": "0.14.0",
"description": "Paseo relay for bridging daemon and client connections",
"files": [
"dist",
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.14.0-beta.2",
"version": "0.14.0",
"description": "Paseo backend server",
"files": [
"dist/server",
@@ -107,11 +107,11 @@
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.14.0-beta.2",
"@getpaseo/highlight": "0.14.0-beta.2",
"@getpaseo/plugin": "0.14.0-beta.2",
"@getpaseo/protocol": "0.14.0-beta.2",
"@getpaseo/relay": "0.14.0-beta.2",
"@getpaseo/client": "0.14.0",
"@getpaseo/highlight": "0.14.0",
"@getpaseo/plugin": "0.14.0",
"@getpaseo/protocol": "0.14.0",
"@getpaseo/relay": "0.14.0",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
@@ -0,0 +1,108 @@
// Opt-in diagnostic for #3905. Run from the repository root:
// node --import tsx packages/server/scripts/reproduce-codex-history-string-limit.mts 520 [legacy|paginated]
// Requires Codex 0.148+ on PATH and approximately 2 GiB of available memory.
// `paginated` migrates the synthetic rollout with `codex migrate-rollouts` first.
import { execFileSync, spawn } from "node:child_process";
import { randomUUID } from "node:crypto";
import { mkdtemp, mkdir, open, rm } from "node:fs/promises";
import path from "node:path";
import pino from "pino";
import { CodexAppServerAgentSession } from "../src/server/agent/providers/codex-app-server-agent.js";
const megabytes = Number(process.argv[2] ?? "520");
if (!Number.isSafeInteger(megabytes) || megabytes < 1 || megabytes > 600) {
throw new Error("Expected an integer size between 1 and 600 MiB");
}
const historyMode = process.argv[3] ?? "legacy";
if (historyMode !== "legacy" && historyMode !== "paginated") {
throw new Error("Expected history mode legacy or paginated");
}
const root = await mkdtemp(path.join(process.cwd(), ".codex-history-repro-"));
const home = path.join(root, "codex-home");
const now = new Date();
const timestamp = now.toISOString();
const threadId = randomUUID();
const turnId = randomUUID();
const sessions = path.join(home, "sessions", ...timestamp.slice(0, 10).split("-"));
await mkdir(sessions, { recursive: true });
const rollout = await open(
path.join(sessions, `rollout-${timestamp.slice(0, 19).replaceAll(":", "-")}-${threadId}.jsonl`),
"w",
);
async function write(type: string, payload: unknown) {
await rollout.write(`${JSON.stringify({ timestamp, type, payload })}\n`);
}
await write("session_meta", {
id: threadId,
timestamp,
cwd: root,
originator: "codex_cli_rs",
cli_version: "0.159.0",
source: "cli",
model_provider: "openai",
history_mode: "legacy",
});
await write("event_msg", { type: "task_started", turn_id: turnId, model_context_window: 200000 });
await write("event_msg", {
type: "user_message",
message: "Synthetic oversized history",
images: [],
local_images: [],
text_elements: [],
});
for (let index = 0; index < megabytes; index++) {
await write("event_msg", {
type: "agent_message",
message: "x".repeat(1024 * 1024),
phase: "commentary",
});
}
await write("event_msg", { type: "task_complete", turn_id: turnId, last_agent_message: "done" });
await rollout.close();
if (historyMode === "paginated") {
execFileSync("codex", ["migrate-rollouts", "--apply", "--thread", threadId], {
env: { ...process.env, CODEX_HOME: home },
stdio: "ignore",
});
}
console.log(
`Opening synthetic ${megabytes} MiB ${historyMode} history through the real Codex app-server`,
);
const session = new CodexAppServerAgentSession(
{ provider: "codex", cwd: root },
{ sessionId: threadId },
pino({ level: "silent" }),
async () =>
spawn(
"codex",
["app-server", "--disable", "plugins", "--disable", "apps", "--disable", "remote_plugin"],
{
stdio: ["pipe", "pipe", "pipe"],
env: { ...process.env, CODEX_HOME: home },
},
),
{ codexHome: home },
false,
false,
false,
undefined,
"history",
);
try {
await session.connect();
let count = 0;
for await (const event of session.streamHistory()) {
if (event.type !== "timeline") continue;
count++;
if (event.item.type === "notification") console.log(event.item.message);
}
console.log(`History loaded: ${count} timeline rows`);
} catch (error) {
// A recoverable session error is acceptable for the crash-isolation assertion.
console.log(
`History rejected without crashing: ${error instanceof Error ? error.message : String(error)}`,
);
} finally {
await session.close();
await rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
}
@@ -956,6 +956,131 @@ test("unavailable steer interrupts once and starts one replacement turn", async
}
});
test("steers a send that arrives while the previous turn is still starting", async () => {
const startHeld = deferred<void>();
class SlowStartSteeringSession extends SteeringTestSession {
override async startTurn(prompt: AgentPromptInput): Promise<{ turnId: string }> {
await startHeld.promise;
return await super.startTurn(prompt);
}
}
const session = new SlowStartSteeringSession({
provider: "codex",
cwd: process.cwd(),
});
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-steer-starting-"));
const manager = new AgentManager({
clients: {
codex: new (class extends TestAgentClient {
override async createSession(): Promise<AgentSession> {
return session;
}
})(),
},
logger,
});
let agentId: string | null = null;
try {
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: undefined,
});
agentId = agent.id;
const steerOptions = (clientMessageId: string) => ({
replaceRunning: true,
activeTurnBehavior: "steer" as const,
runOptions: { clientMessageId },
});
await startAgentRun(manager, agent.id, "FIRST", logger, steerOptions("first-client"));
const second = startAgentRun(
manager,
agent.id,
"SECOND",
logger,
steerOptions("second-client"),
);
startHeld.resolve();
await expect(second).resolves.toEqual({ disposition: "steered" });
expect(session.startPrompts).toEqual(["FIRST"]);
expect(session.interruptCount).toBe(0);
expect(
manager
.fetchTimeline(agent.id, { limit: 0 })
.rows.filter((row) => row.item.type === "user_message")
.map((row) => ({
text: row.item.type === "user_message" ? row.item.text : null,
turnId: row.turnId,
})),
).toEqual([
{ text: "FIRST", turnId: "active-turn-1" },
{ text: "SECOND", turnId: "active-turn-1" },
]);
} finally {
startHeld.resolve();
if (agentId) await manager.closeAgent(agentId).catch(() => undefined);
rmSync(workdir, { recursive: true, force: true });
}
});
test("starts its own turn when the previous turn fails to start under a steer send", async () => {
const startHeld = deferred<void>();
class FailingFirstStartSession extends SteeringTestSession {
override async startTurn(prompt: AgentPromptInput): Promise<{ turnId: string }> {
if (prompt === "FIRST") {
this.startPrompts.push(prompt);
await startHeld.promise;
throw new Error("first start failed");
}
return await super.startTurn(prompt);
}
}
const session = new FailingFirstStartSession({
provider: "codex",
cwd: process.cwd(),
});
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-steer-failed-start-"));
const manager = new AgentManager({
clients: {
codex: new (class extends TestAgentClient {
override async createSession(): Promise<AgentSession> {
return session;
}
})(),
},
logger,
});
let agentId: string | null = null;
try {
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: undefined,
});
agentId = agent.id;
const steerOptions = (clientMessageId: string) => ({
replaceRunning: true,
activeTurnBehavior: "steer" as const,
runOptions: { clientMessageId },
});
await startAgentRun(manager, agent.id, "FIRST", logger, steerOptions("first-client"));
const second = startAgentRun(
manager,
agent.id,
"SECOND",
logger,
steerOptions("second-client"),
);
startHeld.resolve();
await expect(second).resolves.toEqual({ disposition: "turn_started" });
await vi.waitFor(() => expect(session.startPrompts).toEqual(["FIRST", "SECOND"]));
expect(session.interruptCount).toBe(0);
} finally {
startHeld.resolve();
if (agentId) await manager.closeAgent(agentId).catch(() => undefined);
rmSync(workdir, { recursive: true, force: true });
}
});
test("orders an accepted steer before output emitted while acknowledgement is pending", async () => {
const entered = deferred<void>();
const release = deferred<void>();
@@ -2734,7 +2734,7 @@ export class AgentManager {
throw error;
}
if (isStaleProviderSessionError(error)) {
pendingRun.start = { status: "failed", error: error.message };
this.runs.settleForegroundStart(pendingRun, { status: "failed", error: error.message });
agent.pendingReplacement = false;
if (!agent.activeForegroundTurnId) agent.lifecycle = "idle";
this.runs.settleForegroundRun(agentId, pendingRun.token);
@@ -2742,20 +2742,24 @@ export class AgentManager {
}
agent.pendingReplacement = false;
const errorMsg = error instanceof Error ? error.message : "Failed to start turn";
pendingRun.start = { status: "failed", error: errorMsg };
// A terminal rejection belongs after the submitted prompt even though no provider turn exists.
if (options?.clientMessageId) {
this.recordSubmittedPrompt(agent, prompt, options.clientMessageId, "rejected", {
messageId: options.clientMessageId,
this.runs.settleForegroundStart(pendingRun, { status: "failed", error: errorMsg });
try {
// A terminal rejection belongs after the submitted prompt even though no provider turn exists.
if (options?.clientMessageId) {
this.recordSubmittedPrompt(agent, prompt, options.clientMessageId, "rejected", {
messageId: options.clientMessageId,
});
}
await this.handleStreamEvent(agent, {
type: "turn_failed",
provider: agent.provider,
error: errorMsg,
});
this.finalizeForegroundTurn(agent);
} finally {
// A steer waiting on this start is released only once the run is cleared.
this.runs.settleForegroundRun(agentId, pendingRun.token);
}
await this.handleStreamEvent(agent, {
type: "turn_failed",
provider: agent.provider,
error: errorMsg,
});
this.finalizeForegroundTurn(agent);
this.runs.settleForegroundRun(agentId, pendingRun.token);
throw error;
}
}
@@ -2832,7 +2836,7 @@ export class AgentManager {
agent.pendingReplacement = false;
}
const turnStartedAt = new Date();
pendingRun.start = { status: "started", turnId };
this.runs.settleForegroundStart(pendingRun, { status: "started", turnId });
agent.activeForegroundTurnId = turnId;
this.openActiveTurn(agent, turnId, turnStartedAt);
agent.lifecycle = "running";
@@ -3041,6 +3045,9 @@ export class AgentManager {
prompt: AgentPromptInput,
options?: AgentSteerOptions,
): Promise<ActiveTurnSteerDispatchResult> {
// A turn that is still starting has no id to steer yet. Replacing it would drop its
// prompt, so wait until it is accepted (or fails) and steer the turn it became.
await this.runs.waitForForegroundStart(agentId);
const agent = this.requireSessionAgent(agentId);
const expectedTurnId = agent.activeForegroundTurnId ?? agent.activeTurnId;
if (!expectedTurnId) {
@@ -0,0 +1,51 @@
import { describe, expect, test } from "vitest";
import { AgentRunState } from "./agent-run-state.js";
function track(promise: Promise<void>): { readonly resolved: boolean } {
const state = { resolved: false };
void (async () => {
await promise;
state.resolved = true;
})();
return state;
}
async function flushMicrotasks(): Promise<void> {
await new Promise<void>((resolve) => setImmediate(resolve));
}
describe("waitForForegroundStart", () => {
test("resolves once the starting turn has an id", async () => {
const runs = new AgentRunState();
const run = runs.createPendingRun("agent");
const wait = track(runs.waitForForegroundStart("agent"));
await flushMicrotasks();
expect(wait.resolved).toBe(false);
runs.settleForegroundStart(run, { status: "started", turnId: "turn-1" });
await flushMicrotasks();
expect(wait.resolved).toBe(true);
});
test("waits for a failed start's run to be cleared", async () => {
const runs = new AgentRunState();
const run = runs.createPendingRun("agent");
const wait = track(runs.waitForForegroundStart("agent"));
runs.settleForegroundStart(run, { status: "failed", error: "start failed" });
await flushMicrotasks();
expect(wait.resolved).toBe(false);
expect(runs.hasPendingRun("agent")).toBe(true);
runs.settleForegroundRun("agent", run.token);
await flushMicrotasks();
expect(wait.resolved).toBe(true);
});
test("resolves at once when no turn is starting", async () => {
const runs = new AgentRunState();
await expect(runs.waitForForegroundStart("agent")).resolves.toBeUndefined();
});
});
@@ -10,14 +10,18 @@ export interface ForegroundTurnWaiter {
resolveSettled: () => void;
}
type ForegroundRunStart =
| { status: "pending" }
| { status: "started"; turnId: string }
| { status: "failed"; error: string };
export interface PendingForegroundRun {
token: string;
kind: "foreground";
stagedEvents: AgentStreamEvent[];
start:
| { status: "pending" }
| { status: "started"; turnId: string }
| { status: "failed"; error: string };
start: ForegroundRunStart;
turnStartedPromise: Promise<void>;
resolveTurnStarted: () => void;
settled: boolean;
settledPromise: Promise<void>;
resolveSettled: () => void;
@@ -54,6 +58,28 @@ export class AgentRunState {
return run?.kind === "foreground" ? run : null;
}
settleForegroundStart(
run: PendingForegroundRun,
start: Exclude<ForegroundRunStart, { status: "pending" }>,
): void {
run.start = start;
if (start.status === "started") {
run.resolveTurnStarted();
}
}
/**
* Resolves once the agent's foreground run has a turn id or has ended. A failed start
* resolves only once its run is cleared, so callers never see a failed run still tracked.
*/
async waitForForegroundStart(agentId: string): Promise<void> {
const run = this.getPendingRun(agentId);
if (run?.start.status !== "pending") {
return;
}
await Promise.race([run.turnStartedPromise, run.settledPromise]);
}
hasPendingRun(agentId: string): boolean {
return this.getPendingRun(agentId) !== null;
}
@@ -277,10 +303,16 @@ export class ForegroundTurnStream {
}
function createPendingForegroundRun(): PendingForegroundRun {
let resolveTurnStarted!: () => void;
const turnStartedPromise = new Promise<void>((resolvePromise) => {
resolveTurnStarted = resolvePromise;
});
return {
...createTrackedRunState(),
kind: "foreground",
start: { status: "pending" },
turnStartedPromise,
resolveTurnStarted,
stagedEvents: [],
};
}
@@ -30,16 +30,20 @@ function createQueryMock(events: unknown[]): Query {
}
describe("Claude SDK env", () => {
test.each(["node", "bun", process.execPath])(
"identifies the spawned %s process as Paseo after clearing parent session env",
async (command) => {
test.each(
["node", "bun", process.execPath].flatMap((command) =>
[undefined, "Paseo", "custom-client"].map((entrypoint) => ({ command, entrypoint })),
),
)(
"preserves explicit launch identity $entrypoint in $command while clearing inherited session env",
async ({ command, entrypoint }) => {
let output = "";
let exited = Promise.resolve<unknown[]>([]);
const query = claudeQuery(
{ prompt: "", options: {} },
{
runtimeSettings: { env: { CLAUDE_CODE_ENTRYPOINT: "sdk-ts" } },
launchEnv: { CLAUDE_CODE_ENTRYPOINT: "sdk-cli" },
launchEnv: entrypoint === undefined ? {} : { CLAUDE_CODE_ENTRYPOINT: entrypoint },
queryFactory: ({ options }) => {
if (!options.spawnClaudeCodeProcess) {
throw new Error("Missing Claude process launcher");
@@ -65,7 +69,10 @@ describe("Claude SDK env", () => {
try {
expect(await exited).toEqual([0, null]);
expect(JSON.parse(output)).toEqual({ entrypoint: "Paseo", parentSession: null });
expect(JSON.parse(output)).toEqual({
...(entrypoint === undefined ? {} : { entrypoint }),
parentSession: null,
});
} finally {
query.close();
}
@@ -73,8 +73,9 @@ function applyRuntimeSettingsToClaudeOptions(
runtimeSettings,
overlays: [launchEnv],
});
// Set our identity after sanitization clears the parent session's entrypoint.
providerEnvSpec.envOverlay.CLAUDE_CODE_ENTRYPOINT = "Paseo";
// Session-open hooks may explicitly supply a client identity. Keep inherited
// SDK/parent identities cleared while honoring this launch-only contribution.
providerEnvSpec.envOverlay.CLAUDE_CODE_ENTRYPOINT = launchEnv?.CLAUDE_CODE_ENTRYPOINT;
const providerEnv = createExternalProcessEnv(spawnOptions.env, providerEnvSpec.envOverlay);
const selfNodeCommand = isDefaultRuntime
? buildSelfNodeCommand(resolved.args, providerEnv)
@@ -88,6 +88,7 @@ describe("Codex executable discovery", () => {
import { CodexAppServerClient } from "./codex/app-server-transport.js";
import {
createFakeCodexAppServer,
fakeCodexThreadHistory,
type FakeCodexAppServer,
waitForNextPermission,
waitForNextTimelineItem,
@@ -226,6 +227,64 @@ function deferred<T>() {
}
describe("Codex active-turn steering admission", () => {
test("steers an accepted turn whose native id arrives after startTurn returns", async () => {
const appServer = createFakeCodexAppServer({
"turn/steer": () => ({ turn: { id: "native-A" } }),
});
const session = new CodexAppServerAgentSession(
createConfig({ cwd: "/workspace/project" }),
null,
createTestLogger(),
async () => appServer.child,
);
try {
const { turnId } = await session.startTurn("FIRST");
const steer = session.steerActiveTurn("SECOND", { expectedTurnId: turnId });
appServer.startsTurn({ threadId: "thread-1", turnId: "native-A" });
await expect(steer).resolves.toEqual({ status: "accepted" });
expect(
appServer.requests().filter((request) => request.method === "turn/start"),
).toHaveLength(1);
expect(appServer.requests().filter((request) => request.method === "turn/interrupt")).toEqual(
[],
);
expect(appServer.requests().find((request) => request.method === "turn/steer")).toMatchObject(
{
params: {
threadId: "thread-1",
expectedTurnId: "native-A",
input: [{ type: "text", text: "SECOND" }],
},
},
);
appServer.assertNoErrors();
} finally {
await session.close();
}
});
test("releases a waiting steer when the accepted turn ends before identification", async () => {
const appServer = createFakeCodexAppServer();
const session = new CodexAppServerAgentSession(
createConfig({ cwd: "/workspace/project" }),
null,
createTestLogger(),
async () => appServer.child,
);
try {
const { turnId } = await session.startTurn("FIRST");
const steer = session.steerActiveTurn("SECOND", { expectedTurnId: turnId });
appServer.completeTurn();
await expect(steer).resolves.toEqual({ status: "unavailable" });
expect(appServer.requests().filter((request) => request.method === "turn/steer")).toEqual([]);
appServer.assertNoErrors();
} finally {
await session.close();
}
});
test("a steer without the clearing contract leaves permissions open", async () => {
const appServer = createFakeCodexAppServer({
"turn/steer": () => ({ turn: { id: "native-A" } }),
@@ -1933,7 +1992,7 @@ describe("Codex app-server provider", () => {
purpose: "history",
});
expect(threadRequests).toEqual(["thread/read"]);
expect(threadRequests).toEqual(["thread/read", "thread/read"]);
await session.close();
appServer.assertNoErrors();
});
@@ -2010,6 +2069,7 @@ describe("Codex app-server provider", () => {
"thread/loaded/list",
"thread/resume",
"thread/read",
"thread/read",
]);
expect(codex.threadMethods(1)).toEqual(["thread/loaded/list", "thread/resume"]);
expect(codex.manager.getTimeline(codex.agentId)).toEqual(projected);
@@ -2369,7 +2429,7 @@ describe("Codex app-server provider", () => {
cwd: "/workspace/project",
model: "gpt-5.4",
serviceTier: null,
excludeTurns: false,
excludeTurns: true,
persistExtendedHistory: true,
},
]);
@@ -2383,8 +2443,9 @@ describe("Codex app-server provider", () => {
test("rewinds a paginated conversation through the public session capability", async () => {
const appServer = createFakeCodexAppServer({
"thread/read": () => ({
thread: { id: "thread-1", historyMode: "paginated", turns: [] },
...fakeCodexThreadHistory({
"thread-1": { historyMode: "paginated", turns: [] },
"forked-thread": { historyMode: "paginated", turns: [] },
}),
"thread/rollback": () => {
throw new Error("paginated threads do not support thread/rollback");
@@ -2429,7 +2490,7 @@ describe("Codex app-server provider", () => {
cwd: "/workspace/project",
model: "gpt-5.4",
serviceTier: null,
excludeTurns: false,
excludeTurns: true,
persistExtendedHistory: true,
},
]);
@@ -2446,11 +2507,10 @@ describe("Codex app-server provider", () => {
async (historyMode) => {
const appServer = createFakeCodexAppServer(
historyMode === "paginated"
? {
"thread/read": () => ({
thread: { id: "thread-1", historyMode: "paginated", turns: [] },
}),
}
? fakeCodexThreadHistory({
"thread-1": { historyMode: "paginated", turns: [] },
"forked-thread": { historyMode: "paginated", turns: [] },
})
: undefined,
);
const customCodexConfig = {
@@ -4889,6 +4949,7 @@ describe("Codex app-server provider", () => {
}
expect(requests.map((request) => [request.method, request.params])).toEqual([
["thread/read", { threadId: "test-thread", includeTurns: false }],
["thread/read", { threadId: "test-thread", includeTurns: true }],
]);
expect(history).toEqual([
@@ -92,12 +92,18 @@ import {
type CodexAppServerTraceContext,
} from "./codex/app-server-transport.js";
import { type CodexUserMessageTurnIndex, revertCodexConversation } from "./codex/rewind.js";
import {
type CodexHistoryRow,
CodexThreadHistoryTooLargeError,
readCodexThreadHistory,
} from "./codex/thread-history.js";
import {
materializeProviderImage,
renderProviderImageOutputAsAssistantMarkdown,
type ProviderImageOutput,
} from "./provider-image-output.js";
import { normalizeProviderReplayTimestamp } from "../provider-history-timestamps.js";
import { limitAgentTimelineItemContent } from "../agent-timeline-content.js";
import {
formatProviderDiagnostic,
formatProviderDiagnosticError,
@@ -1804,22 +1810,13 @@ function readCodexHistoryTimestamp(item: unknown): string | null {
);
}
function readCodexTurnHistoryTimestamp(
turn: unknown,
/** Falls back to the row's start time for a user message and its end time otherwise. */
function readCodexHistoryRowTimestamp(
row: CodexHistoryRow,
timelineItem: AgentTimelineItem,
): string | null {
const record = toObjectRecord(turn);
if (!record) {
return null;
}
const startedAt =
normalizeProviderReplayTimestamp(record.startedAt) ??
normalizeProviderReplayTimestamp(record.started_at);
const completedAt =
normalizeProviderReplayTimestamp(record.completedAt) ??
normalizeProviderReplayTimestamp(record.completed_at);
const startedAt = normalizeProviderReplayTimestamp(row.startedAt);
const completedAt = normalizeProviderReplayTimestamp(row.completedAt);
if (timelineItem.type === "user_message") {
return startedAt ?? completedAt;
}
@@ -2063,84 +2060,19 @@ async function threadItemToTimelineEntries(
return [timelineItem, ...(await mcpToolResultImagesToTimeline(item))];
}
const CodexThreadReadResponseSchema = z
.object({
thread: z
.object({
turns: z
.array(
z
.object({
items: z.array(z.unknown()).default([]),
})
.passthrough(),
)
.default([]),
})
.passthrough()
.default({ turns: [] }),
})
.passthrough();
type CodexThreadReadResponse = z.infer<typeof CodexThreadReadResponseSchema>;
type CodexThreadReadRequest = (threadId: string) => Promise<unknown>;
async function requestCodexThreadHistory(
requestThread: CodexThreadReadRequest,
threadId: string,
): Promise<CodexThreadReadResponse> {
const response = await requestThread(threadId);
return CodexThreadReadResponseSchema.parse(response);
}
async function loadCodexThreadHistoryTimeline(params: {
client: CodexAppServerClientLike;
threadId: string;
cwd: string | null;
requestThread: CodexThreadReadRequest;
}): Promise<CodexThreadHistoryProjection> {
const response = await requestCodexThreadHistory(params.requestThread, params.threadId);
const timeline: PersistedTimelineEntry[] = [];
const subAgentTimelineIndexByThreadId = new Map<string, number>();
for (const turn of response.thread.turns) {
for (const item of turn.items) {
const historicalSubAgentActivity = readCodexSubAgentActivity(item);
if (historicalSubAgentActivity) {
const existingIndex = subAgentTimelineIndexByThreadId.get(
historicalSubAgentActivity.agentThreadId,
);
if (existingIndex !== undefined) {
const activityTimelineItem = await threadItemToTimeline(item, { cwd: params.cwd });
updateHistoricalSubAgentActivity(
timeline,
existingIndex,
historicalSubAgentActivity.kind,
activityTimelineItem?.type === "tool_call" &&
activityTimelineItem.detail.type === "sub_agent"
? activityTimelineItem.detail.subAgentType
: undefined,
);
continue;
}
}
for (const timelineItem of await threadItemToTimelineEntries(item, { cwd: params.cwd })) {
const timestamp =
readCodexHistoryTimestamp(item) ?? readCodexTurnHistoryTimestamp(turn, timelineItem);
const settledTimelineItem =
historicalSubAgentActivity && timelineItem.type === "tool_call"
? settleHistoricalSubAgentActivity(timelineItem, historicalSubAgentActivity.kind)
: timelineItem;
timeline.push({
item: settledTimelineItem,
timestamp: timestamp ?? undefined,
...(timelineItem.type === "user_message" && typeof turn.id === "string"
? { providerTurnId: turn.id }
: {}),
});
for (const childThreadId of readCodexHistoricalSubAgentThreadIds(item)) {
subAgentTimelineIndexByThreadId.set(childThreadId, timeline.length - 1);
}
}
}
try {
await projectCodexThreadHistory(params, { timeline, subAgentTimelineIndexByThreadId });
} catch (error) {
if (!(error instanceof CodexThreadHistoryTooLargeError)) throw error;
// The session stays usable; the row says why the history stops here.
timeline.push({ item: { type: "notification", level: "warning", message: error.message } });
}
const subAgentRoutes = Array.from(subAgentTimelineIndexByThreadId.entries()).flatMap(
([childThreadId, timelineIndex]): PersistedSubAgentRoute[] => {
@@ -2153,11 +2085,55 @@ async function loadCodexThreadHistoryTimeline(params: {
return { timeline, subAgentRoutes };
}
function readCodexThread(client: CodexAppServerClientLike, threadId: string): Promise<unknown> {
return client.request("thread/read", {
threadId,
includeTurns: true,
});
async function projectCodexThreadHistory(
params: { client: CodexAppServerClientLike; threadId: string; cwd: string | null },
projection: {
timeline: PersistedTimelineEntry[];
subAgentTimelineIndexByThreadId: Map<string, number>;
},
): Promise<void> {
const { timeline, subAgentTimelineIndexByThreadId } = projection;
for await (const row of readCodexThreadHistory(params.client, params.threadId)) {
const { item } = row;
const historicalSubAgentActivity = readCodexSubAgentActivity(item);
if (historicalSubAgentActivity) {
const existingIndex = subAgentTimelineIndexByThreadId.get(
historicalSubAgentActivity.agentThreadId,
);
if (existingIndex !== undefined) {
const activityTimelineItem = await threadItemToTimeline(item, { cwd: params.cwd });
updateHistoricalSubAgentActivity(
timeline,
existingIndex,
historicalSubAgentActivity.kind,
activityTimelineItem?.type === "tool_call" &&
activityTimelineItem.detail.type === "sub_agent"
? activityTimelineItem.detail.subAgentType
: undefined,
);
continue;
}
}
for (const timelineItem of await threadItemToTimelineEntries(item, { cwd: params.cwd })) {
const timestamp =
readCodexHistoryTimestamp(item) ?? readCodexHistoryRowTimestamp(row, timelineItem);
const settledTimelineItem =
historicalSubAgentActivity && timelineItem.type === "tool_call"
? settleHistoricalSubAgentActivity(timelineItem, historicalSubAgentActivity.kind)
: timelineItem;
timeline.push({
// Cap each item as it is projected so a large history is never held uncapped.
item: limitAgentTimelineItemContent(settledTimelineItem),
timestamp: timestamp ?? undefined,
...(timelineItem.type === "user_message" && row.turnId !== undefined
? { providerTurnId: row.turnId }
: {}),
});
for (const childThreadId of readCodexHistoricalSubAgentThreadIds(item)) {
subAgentTimelineIndexByThreadId.set(childThreadId, timeline.length - 1);
}
}
}
}
export async function forkCodexThread(
@@ -3997,9 +3973,9 @@ export class CodexAppServerAgentSession implements AgentSession {
return { timeline: [], providerSubagentEvents: [] };
}
const { timeline, subAgentRoutes } = await loadCodexThreadHistoryTimeline({
client,
threadId: this.currentThreadId,
cwd: this.config.cwd ?? null,
requestThread: (threadIdToRead) => readCodexThread(client, threadIdToRead),
});
this.subAgentCallsByCallId.clear();
this.subAgentCallIdByChildThreadId.clear();
@@ -4055,9 +4031,9 @@ export class CodexAppServerAgentSession implements AgentSession {
});
try {
const childHistory = await loadCodexThreadHistoryTimeline({
client,
threadId: next.route.childThreadId,
cwd: this.config.cwd ?? null,
requestThread: (childThreadId) => readCodexThread(client, childThreadId),
});
for (const entry of childHistory.timeline) {
this.emitProviderSubagentTimeline(next.route.childThreadId, entry.item, entry.timestamp);
@@ -4456,9 +4432,18 @@ export class CodexAppServerAgentSession implements AgentSession {
): Promise<SteerResult> {
const client = this.client;
const threadId = this.currentThreadId;
const nativeTurnId = this.currentTurnId;
let nativeTurnId = this.currentTurnId;
const foregroundTurnId = this.activeForegroundTurnId;
if (!client || !threadId || !nativeTurnId || foregroundTurnId !== options.expectedTurnId) {
if (!client || !threadId || foregroundTurnId !== options.expectedTurnId) {
return { status: "unavailable" };
}
const pendingIdentification = this.pendingForegroundTurnIdentification;
// The start acknowledgement precedes the notification that identifies the native
// turn. Keep this steer attached to the accepted foreground turn while it arrives.
if (!nativeTurnId && pendingIdentification?.foregroundTurnId === foregroundTurnId) {
nativeTurnId = await pendingIdentification.promise;
}
if (!nativeTurnId) {
return { status: "unavailable" };
}
if (await this.resolveSlashCommandInvocation(prompt)) return { status: "unavailable" };
@@ -0,0 +1,53 @@
import { execFile } from "node:child_process";
import { mkdtemp, rm } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { expect, test } from "vitest";
// Regression for #3905. Real Codex, synthetic on-disk history, no model turns.
// Opt in because the oversized cases need approximately 2 GiB of memory and over
// 1 GiB of temporary storage.
const enabled = process.env.PASEO_CODEX_HISTORY_STRING_LIMIT_REPRO === "1";
const script = fileURLToPath(
new URL("../../../../scripts/reproduce-codex-history-string-limit.mts", import.meta.url),
);
test.runIf(enabled).each([
{ megabytes: 2, historyMode: "legacy", expected: "History loaded: 3 timeline rows" },
{ megabytes: 520, historyMode: "legacy", expected: "codex migrate-rollouts --apply --thread" },
{ megabytes: 520, historyMode: "paginated", expected: "History loaded: 521 timeline rows" },
])(
"opening $megabytes MiB of $historyMode Codex history does not crash its hosting process",
async ({ megabytes, historyMode, expected }) => {
const root = await mkdtemp(path.join(process.cwd(), ".codex-history-test-"));
try {
const result = await new Promise<{ code: number; stdout: string; stderr: string }>(
(resolve) => {
const child = execFile(
process.execPath,
["--import", "tsx", script, String(megabytes), historyMode],
{
cwd: root,
detached: true,
timeout: 120_000,
maxBuffer: 1024 * 1024,
},
(error, stdout, stderr) => {
try {
if (child.pid) process.kill(-child.pid, "SIGKILL");
} catch (killError) {
if ((killError as NodeJS.ErrnoException).code !== "ESRCH") throw killError;
}
resolve({ code: error ? Number(error.code ?? 1) : 0, stdout, stderr });
},
);
},
);
expect(result.code, result.stderr).toBe(0);
expect(result.stdout).toContain(expected);
} finally {
await rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
}
},
180_000,
);
@@ -185,14 +185,41 @@ describe("Codex app-server transport", () => {
});
describe("Codex stdout byte admission", () => {
test("fails pending requests and the session before joining an oversized line", async () => {
test("an oversized response fails only its own request", async () => {
const child = createCodexAppServerChildProcess();
const client = new CodexAppServerClient(child, createTestLogger());
const terminations: Error[] = [];
client.setUnexpectedTerminationHandler((error) => terminations.push(error));
const oversized = client.request("thread/items/list", {}).catch((error: Error) => error);
const other = client.request("model/list", {});
const chunk = Buffer.alloc(64 * 1024, "x");
child.stdout.write('{"id":1,"result":"');
for (let index = 0; index < 2049; index++) child.stdout.write(chunk);
child.stdout.write('"}\n{"id":2,"result":{"data":[]}}\n');
expect(await oversized).toMatchObject({
name: "CodexAppServerResponseTooLargeError",
method: "thread/items/list",
limitBytes: 128 * 1024 * 1024,
});
await expect(other).resolves.toEqual({ data: [] });
const next = client.request("model/list", {});
child.stdout.write('{"id":3,"result":{"data":["next"]}}\n');
await expect(next).resolves.toEqual({ data: ["next"] });
expect(terminations).toEqual([]);
await client.dispose();
child.stdout.end();
child.stderr.end();
});
test("an oversized notification fails pending requests and the session", async () => {
const child = createCodexAppServerChildProcess();
const client = new CodexAppServerClient(child, createTestLogger());
const terminations: Error[] = [];
client.setUnexpectedTerminationHandler((error) => terminations.push(error));
const request = client.request("thread/resume", {}, 1000).catch((error: Error) => error);
const chunk = Buffer.alloc(64 * 1024, "x");
child.stdout.write('{"id":1,"result":"');
child.stdout.write('{"method":"item/completed","params":"');
for (let index = 0; index < 2048; index++) child.stdout.write(chunk);
const error = await request;
expect(error).toMatchObject({
@@ -33,11 +33,26 @@ interface JsonRpcNotification {
}
interface PendingRequest {
method: string;
resolve: (value: unknown) => void;
reject: (error: Error) => void;
timer: NodeJS.Timeout;
}
/** A response past the provider line cap. Only its request fails; the session stays up. */
export class CodexAppServerResponseTooLargeError extends Error {
constructor(
readonly method: string,
readonly limitBytes: number,
) {
super(`Codex app-server response to ${method} exceeds ${limitBytes} bytes`);
this.name = "CodexAppServerResponseTooLargeError";
}
}
// Codex serializes a response with `id` first, so its head names the request.
const OVERSIZED_RESPONSE_HEAD = /^\{"id":(\d+),"result":/;
export class CodexAppServerRpcError extends Error {
constructor(
message: string,
@@ -258,7 +273,7 @@ export class CodexAppServerClient {
this.pending.delete(id);
reject(new Error(`Codex app-server request timed out for ${method}`));
}, timeoutMs);
this.pending.set(id, { resolve, reject, timer });
this.pending.set(id, { method, resolve, reject, timer });
});
}
@@ -304,8 +319,21 @@ export class CodexAppServerClient {
}
private handleStreamLimit(failure: ProviderStreamLimitError): void {
// A rejected response/notification cannot be replayed safely. Fail the session,
const responseId = OVERSIZED_RESPONSE_HEAD.exec(failure.lineHead)?.[1];
const pending = responseId === undefined ? undefined : this.pending.get(Number(responseId));
if (pending) {
clearTimeout(pending.timer);
this.pending.delete(Number(responseId));
this.logger.warn(
{ method: pending.method, limitBytes: failure.limitBytes },
"Codex app-server response exceeded the line cap",
);
pending.reject(new CodexAppServerResponseTooLargeError(pending.method, failure.limitBytes));
return;
}
// A lost notification or server request cannot be replayed. Fail the session,
// including live turns with no pending RPC, rather than dropping a stream unit.
this.stdoutLineReader.close();
this.handleUnexpectedTermination(failure);
void this.dispose().catch((err) => {
this.logger.warn({ err }, "Failed to terminate oversized Codex stdout producer");
@@ -108,7 +108,7 @@ describe("Codex Rewind", () => {
cwd: "/workspace/project",
model: "gpt-5.4-mini",
serviceTier: null,
excludeTurns: false,
excludeTurns: true,
persistExtendedHistory: true,
},
]);
@@ -190,7 +190,7 @@ describe("Codex Rewind", () => {
cwd: "/workspace/project",
model: "gpt-5.4-mini",
serviceTier: null,
excludeTurns: false,
excludeTurns: true,
persistExtendedHistory: true,
},
]);
@@ -8,6 +8,7 @@ import {
parseCodexThreadForkResponse,
parseCodexThreadRollbackResponse,
} from "./app-server-transport.js";
import { readCodexThreadHistoryMode } from "./thread-history.js";
export interface CodexRewindClient {
forkThread?(params: CodexThreadForkParams): Promise<CodexThreadForkResponse>;
@@ -20,26 +21,6 @@ export interface CodexUserMessageTurnIndex {
count(): number;
}
type CodexThreadHistoryMode = "legacy" | "paginated";
async function readCodexThreadHistoryMode(
client: CodexRewindClient,
threadId: string,
): Promise<CodexThreadHistoryMode> {
const response = await client.request("thread/read", { threadId, includeTurns: false });
if (typeof response !== "object" || response === null || !("thread" in response)) {
throw new Error("Codex thread/read did not return thread metadata");
}
const thread = response.thread;
if (typeof thread !== "object" || thread === null || !("historyMode" in thread)) {
return "legacy";
}
if (thread.historyMode === "legacy" || thread.historyMode === "paginated") {
return thread.historyMode;
}
throw new Error(`Codex thread/read returned unknown history mode ${String(thread.historyMode)}`);
}
async function forkCodexThread(
client: CodexRewindClient,
params: CodexThreadForkParams,
@@ -95,7 +76,8 @@ export async function revertCodexConversation(input: {
model: input.model ?? null,
serviceTier: input.serviceTier ?? null,
...(input.config ? { config: input.config } : {}),
excludeTurns: false,
// Paseo re-reads the rewound thread through paged history, not the fork reply.
excludeTurns: true,
persistExtendedHistory: true,
};
@@ -3,6 +3,10 @@ import { EventEmitter } from "node:events";
import { PassThrough } from "node:stream";
import type { AgentSession, AgentStreamEvent } from "../../../agent-sdk-types.js";
import { CODEX_PROVIDER_LINE_BYTES } from "../app-server-transport.js";
/** A handler result that the fake writes as a response line past the provider line cap. */
export const OVERSIZED_RESPONSE = Symbol("oversized Codex response");
type JsonObject = Record<string, unknown>;
type FakeCodexAppServerHandler = (params: unknown) => unknown;
@@ -229,8 +233,18 @@ export function createFakeCodexAppServer(
return;
}
Promise.resolve(handler(message.params))
let response: unknown;
try {
response = handler(message.params);
} catch (error) {
response = Promise.reject(error);
}
Promise.resolve(response)
.then((result) => {
if (result === OVERSIZED_RESPONSE) {
writeOversizedResponse(message.id as number);
return undefined;
}
const rpcError = toJsonObject(result).__jsonRpcError;
if (rpcError) {
child.stdout.write(`${JSON.stringify({ id: message.id, error: rpcError })}\n`);
@@ -313,6 +327,16 @@ export function createFakeCodexAppServer(
});
}
// Codex writes `id` first, then a result past the provider line cap.
function writeOversizedResponse(id: number): void {
child.stdout.write(`{"id":${id},"result":{"data":[{"item":{"text":"`);
const chunk = Buffer.alloc(1024 * 1024, "x");
for (let written = 0; written <= CODEX_PROVIDER_LINE_BYTES; written += chunk.byteLength) {
child.stdout.write(chunk);
}
child.stdout.write('"}}]}}\n');
}
function writeNotification(method: string, params: JsonObject): void {
child.stdout.write(`${JSON.stringify({ method, params })}\n`);
}
@@ -696,3 +720,81 @@ export function waitForProviderSubagent(
): Promise<ProviderSubagentEvent> {
return waitForNextEvent(session, "provider_subagent", (event) => event.event.id === id);
}
export interface FakeCodexHistoryTurn {
id: string;
startedAt?: number;
completedAt?: number;
items: JsonObject[];
}
export interface FakeCodexHistoryThread {
/** Omitted on Codex builds older than 0.143, which only have legacy history. */
historyMode?: "legacy" | "paginated";
turns: FakeCodexHistoryTurn[];
}
export interface FakeCodexHistoryOptions {
/** Any response carrying one of these items is written past the line cap. */
oversizedItemIds?: string[];
}
/**
* Codex's persisted thread history: `thread/read` for metadata and legacy full
* reads, `thread/items/list` for paginated threads. Paginated threads reject
* the full read as Codex 0.145 and 0.146 do.
*/
export function fakeCodexThreadHistory(
threads: Record<string, FakeCodexHistoryThread>,
options: FakeCodexHistoryOptions = {},
): Record<string, FakeCodexAppServerHandler> {
const oversizedItemIds = new Set(options.oversizedItemIds ?? []);
const isOversized = (items: JsonObject[]) =>
items.some((item) => oversizedItemIds.has(String(item.id)));
function readThread(params: unknown): { id: string; thread: FakeCodexHistoryThread } {
const id = String(toJsonObject(params).threadId);
const thread = threads[id];
if (!thread) throw new Error(`thread not found: ${id}`);
return { id, thread };
}
return {
"thread/read": (params) => {
const { id, thread } = readThread(params);
if (!toJsonObject(params).includeTurns) {
return {
thread: { id, ...(thread.historyMode ? { historyMode: thread.historyMode } : {}) },
};
}
if (thread.historyMode === "paginated") {
throw new Error("paginated threads do not support thread/read(includeTurns=true)");
}
if (isOversized(thread.turns.flatMap((turn) => turn.items))) return OVERSIZED_RESPONSE;
return { thread: { id, turns: thread.turns } };
},
"thread/items/list": (params) => {
const { thread } = readThread(params);
if (thread.historyMode !== "paginated") {
throw new Error("thread/items/list is not supported for legacy threads");
}
const request = toJsonObject(params);
// Codex records each item's own times; the fake gives every item its turn's.
const rows = thread.turns.flatMap((turn) =>
turn.items.map((item) => ({
turnId: turn.id,
item,
startedAtMs: turn.startedAt === undefined ? null : turn.startedAt * 1000,
completedAtMs: turn.completedAt === undefined ? null : turn.completedAt * 1000,
})),
);
const limit = Math.min(typeof request.limit === "number" ? request.limit : 25, 100);
const start =
typeof request.cursor === "string" ? Number(request.cursor.slice("cursor-".length)) : 0;
const end = Math.min(rows.length, start + limit);
const data = rows.slice(start, end);
if (isOversized(data.map((row) => row.item))) return OVERSIZED_RESPONSE;
return { data, nextCursor: end < rows.length ? `cursor-${end}` : null };
},
};
}
@@ -0,0 +1,220 @@
import type { ChildProcessWithoutNullStreams } from "node:child_process";
import { describe, expect, test } from "vitest";
import { createTestLogger } from "../../../../test-utils/test-logger.js";
import type { AgentSession, AgentStreamEvent } from "../../agent-sdk-types.js";
import { CodexAppServerAgentClient } from "../codex-app-server-agent.js";
import {
createFakeCodexAppServer,
type FakeCodexAppServer,
type FakeCodexHistoryOptions,
type FakeCodexHistoryThread,
fakeCodexThreadHistory,
} from "./test-utils/fake-app-server.js";
function userItem(id: string, text: string) {
return { type: "userMessage", id, content: [{ type: "text", text }] };
}
function agentItem(id: string, text: string) {
return { type: "agentMessage", id, text };
}
async function openCodexThread(
thread: FakeCodexHistoryThread,
options?: FakeCodexHistoryOptions,
): Promise<{ appServer: FakeCodexAppServer; session: AgentSession }> {
const appServer = createFakeCodexAppServer(
fakeCodexThreadHistory({ "saved-thread": thread }, options),
);
const provider = new CodexAppServerAgentClient(createTestLogger());
const internals = provider as unknown as {
goalsEnabledPromise: Promise<boolean> | null;
autoReviewEnabledPromise: Promise<boolean> | null;
spawnAppServer: () => Promise<ChildProcessWithoutNullStreams>;
};
internals.goalsEnabledPromise = Promise.resolve(false);
internals.autoReviewEnabledPromise = Promise.resolve(false);
internals.spawnAppServer = async () => appServer.child;
const session = await provider.resumeSession({ sessionId: "saved-thread" });
return { appServer, session };
}
async function readHistory(session: AgentSession): Promise<AgentStreamEvent[]> {
const events: AgentStreamEvent[] = [];
for await (const event of session.streamHistory()) events.push(event);
return events;
}
function timelineItems(events: AgentStreamEvent[]) {
return events.flatMap((event) => (event.type === "timeline" ? [event.item] : []));
}
function historyRequests(appServer: FakeCodexAppServer) {
return appServer
.requests()
.filter(
(request) =>
request.method === "thread/read" ||
request.method === "thread/turns/list" ||
request.method === "thread/items/list",
)
.map((request) => ({ method: request.method, params: request.params }));
}
describe("Codex thread history", () => {
test("loads a paginated thread through pages", async () => {
const answers = Array.from({ length: 150 }, (_, index) =>
agentItem(`agent-${index}`, `Answer ${index}`),
);
const { appServer, session } = await openCodexThread({
historyMode: "paginated",
turns: [
{
id: "turn-1",
startedAt: 1_778_832_941,
completedAt: 1_778_833_000,
items: [userItem("user-1", "First"), ...answers.slice(0, 120)],
},
{
id: "turn-2",
startedAt: 1_778_833_094,
completedAt: 1_778_833_100,
items: [userItem("user-2", "Second"), ...answers.slice(120)],
},
],
});
try {
const events = await readHistory(session);
expect(timelineItems(events)).toEqual([
{ type: "user_message", text: "First", messageId: "user-1" },
...answers.slice(0, 120).map((answer) => ({
type: "assistant_message",
text: answer.text,
messageId: answer.id,
})),
{ type: "user_message", text: "Second", messageId: "user-2" },
...answers.slice(120).map((answer) => ({
type: "assistant_message",
text: answer.text,
messageId: answer.id,
})),
]);
const timestamps = events.flatMap((event) =>
event.type === "timeline" ? [event.timestamp] : [],
);
expect(timestamps[0]).toBe("2026-05-15T08:15:41.000Z");
expect(timestamps[1]).toBe("2026-05-15T08:16:40.000Z");
expect(timestamps[121]).toBe("2026-05-15T08:18:14.000Z");
const requests = historyRequests(appServer);
expect(requests).not.toContainEqual(
expect.objectContaining({ params: expect.objectContaining({ includeTurns: true }) }),
);
const itemPages = requests.filter((request) => request.method === "thread/items/list");
expect(itemPages.length).toBeGreaterThan(1);
for (const page of itemPages) {
expect(page.params).toMatchObject({ threadId: "saved-thread", sortDirection: "asc" });
expect((page.params as { limit: number }).limit).toBeLessThanOrEqual(100);
}
} finally {
await session.close();
}
appServer.assertNoErrors();
});
test("loads a paginated thread whose full read Codex 0.145 rejects", async () => {
const { appServer, session } = await openCodexThread({
historyMode: "paginated",
turns: [{ id: "turn-1", items: [userItem("user-1", "Hello"), agentItem("agent-1", "Hi")] }],
});
try {
expect(timelineItems(await readHistory(session))).toEqual([
{ type: "user_message", text: "Hello", messageId: "user-1" },
{ type: "assistant_message", text: "Hi", messageId: "agent-1" },
]);
} finally {
await session.close();
}
appServer.assertNoErrors();
});
test("a page too big to send shows a warning row and stops loading", async () => {
const answers = Array.from({ length: 120 }, (_, index) =>
agentItem(`agent-${index}`, `Answer ${index}`),
);
const { appServer, session } = await openCodexThread(
{
historyMode: "paginated",
turns: [
{ id: "turn-1", items: [userItem("user-1", "One"), ...answers] },
{ id: "turn-2", items: [userItem("user-2", "Two"), agentItem("huge", "never sent")] },
],
},
{ oversizedItemIds: ["huge"] },
);
const loaded = [
{ type: "user_message", text: "One", messageId: "user-1" },
...answers.slice(0, 99).map((answer) => ({
type: "assistant_message",
text: answer.text,
messageId: answer.id,
})),
{
type: "notification",
level: "warning",
message: "The rest of this Codex thread's history is too large to load.",
},
];
try {
expect(timelineItems(await readHistory(session))).toEqual(loaded);
// The oversized reply failed only its own request; the session still answers.
expect(timelineItems(await readHistory(session))).toEqual(loaded);
} finally {
await session.close();
}
appServer.assertNoErrors();
});
test("loads a legacy thread that fits in one read", async () => {
const { appServer, session } = await openCodexThread({
historyMode: "legacy",
turns: [{ id: "turn-1", items: [userItem("user-1", "Hello"), agentItem("agent-1", "Hi")] }],
});
try {
expect(timelineItems(await readHistory(session))).toEqual([
{ type: "user_message", text: "Hello", messageId: "user-1" },
{ type: "assistant_message", text: "Hi", messageId: "agent-1" },
]);
expect(historyRequests(appServer).map((request) => request.method)).not.toContain(
"thread/items/list",
);
} finally {
await session.close();
}
appServer.assertNoErrors();
});
test("a legacy thread too big for one read shows the migration command", async () => {
const { appServer, session } = await openCodexThread(
{
historyMode: "legacy",
turns: [{ id: "turn-1", items: [userItem("user-1", "Hello"), agentItem("huge", "x")] }],
},
{ oversizedItemIds: ["huge"] },
);
const migrationRow = {
type: "notification",
level: "warning",
message: expect.stringContaining("codex migrate-rollouts --apply --thread saved-thread"),
};
try {
expect(timelineItems(await readHistory(session))).toEqual([migrationRow]);
// The session stays up for the user to prompt until they archive it to migrate.
expect(timelineItems(await readHistory(session))).toEqual([migrationRow]);
} finally {
await session.close();
}
appServer.assertNoErrors();
});
});
@@ -0,0 +1,168 @@
import { z } from "zod";
import { CodexAppServerResponseTooLargeError } from "./app-server-transport.js";
// Codex clamps history pages to 100 rows.
const PAGE_SIZE = 100;
export interface CodexThreadHistoryClient {
request(method: string, params?: unknown): Promise<unknown>;
}
export type CodexThreadHistoryMode = "legacy" | "paginated";
export interface CodexHistoryRow {
item: unknown;
turnId: string | undefined;
/** The item's times on a paginated thread; its turn's on a legacy thread. */
startedAt: unknown;
completedAt: unknown;
}
/** A history reply over the line cap; the rows before it were already read. */
export class CodexThreadHistoryTooLargeError extends Error {
constructor(message: string) {
super(message);
this.name = "CodexThreadHistoryTooLargeError";
}
}
const CodexThreadMetadataSchema = z
.object({
thread: z.object({ historyMode: z.enum(["legacy", "paginated"]).optional() }).passthrough(),
})
.passthrough();
const CodexLegacyThreadReadSchema = z
.object({
thread: z
.object({
turns: z
.array(
z
.object({
id: z.string().optional(),
startedAt: z.unknown().optional(),
completedAt: z.unknown().optional(),
items: z.array(z.unknown()).default([]),
})
.passthrough(),
)
.default([]),
})
.passthrough()
.default({ turns: [] }),
})
.passthrough();
const CodexItemsPageSchema = z
.object({
data: z.array(
z
.object({
turnId: z.string(),
item: z.unknown(),
startedAtMs: z.number().nullable().optional(),
completedAtMs: z.number().nullable().optional(),
})
.passthrough(),
),
nextCursor: z.string().nullable().optional(),
})
.passthrough();
/** Codex builds before 0.143 report no history mode; their threads are legacy. */
export async function readCodexThreadHistoryMode(
client: CodexThreadHistoryClient,
threadId: string,
): Promise<CodexThreadHistoryMode> {
const response = CodexThreadMetadataSchema.parse(
await client.request("thread/read", { threadId, includeTurns: false }),
);
return response.thread.historyMode ?? "legacy";
}
/**
* Reads a thread's persisted items in order. Paginated threads are read in
* pages; a legacy thread arrives in one `thread/read` reply, as Codex offers
* no pages for it.
*/
export async function* readCodexThreadHistory(
client: CodexThreadHistoryClient,
threadId: string,
): AsyncGenerator<CodexHistoryRow> {
if ((await readCodexThreadHistoryMode(client, threadId)) === "paginated") {
yield* readPaginatedHistory(client, threadId);
} else {
yield* readLegacyHistory(client, threadId);
}
}
async function* readLegacyHistory(
client: CodexThreadHistoryClient,
threadId: string,
): AsyncGenerator<CodexHistoryRow> {
const response = await requestHistory(
client,
"thread/read",
{ threadId, includeTurns: true },
`This Codex thread's history is too large to load in one read. ` +
`To move it to paginated history, archive this agent, ` +
`run \`codex migrate-rollouts --apply --thread ${threadId}\` (Codex 0.148 or newer), ` +
`then unarchive the agent.`,
);
for (const turn of CodexLegacyThreadReadSchema.parse(response).thread.turns) {
for (const item of turn.items) {
yield { item, turnId: turn.id, startedAt: turn.startedAt, completedAt: turn.completedAt };
}
}
}
async function* readPaginatedHistory(
client: CodexThreadHistoryClient,
threadId: string,
): AsyncGenerator<CodexHistoryRow> {
const seenCursors = new Set<string>();
let cursor: string | undefined;
for (;;) {
const page = CodexItemsPageSchema.parse(
await requestHistory(
client,
"thread/items/list",
{ threadId, limit: PAGE_SIZE, sortDirection: "asc", cursor },
"The rest of this Codex thread's history is too large to load.",
),
);
for (const row of page.data) {
yield {
item: row.item,
turnId: row.turnId,
startedAt: row.startedAtMs,
completedAt: row.completedAtMs,
};
}
if (page.nextCursor == null) return;
// Codex's own client guards against a cursor that does not advance.
if (seenCursors.has(page.nextCursor)) {
throw new Error(`Codex repeated history cursor for thread ${threadId}`);
}
seenCursors.add(page.nextCursor);
cursor = page.nextCursor;
}
}
async function requestHistory(
client: CodexThreadHistoryClient,
method: string,
params: unknown,
tooLargeMessage: string,
): Promise<unknown> {
try {
return await client.request(method, params);
} catch (error) {
if (error instanceof CodexAppServerResponseTooLargeError) {
throw new CodexThreadHistoryTooLargeError(tooLargeMessage);
}
throw error;
}
}
@@ -1,10 +1,17 @@
// Provider-process stdout only; these are not app/daemon wire limits.
export const JSONL_PROVIDER_LINE_BYTES = 64 * 1024 * 1024;
// Enough of an oversized line to read which response it was, e.g. `{"id":12,"result":`.
const OVERSIZED_LINE_HEAD_BYTES = 64;
export class ProviderStreamLimitError extends Error {
readonly code = "PROVIDER_STREAM_LINE_TOO_LARGE";
constructor(readonly limitBytes: number) {
constructor(
readonly limitBytes: number,
/** The first bytes of the oversized line; the rest is discarded unread. */
readonly lineHead = "",
) {
super(
`Provider stdout line exceeds ${limitBytes} bytes. Start a new thread or reduce the provider response size.`,
);
@@ -12,10 +19,26 @@ export class ProviderStreamLimitError extends Error {
}
}
/** Admits raw UTF-8 bytes before retaining, decoding or joining a complete line. */
function readLineHead(parts: Buffer[]): string {
const head: Buffer[] = [];
let bytes = 0;
for (const part of parts) {
if (bytes >= OVERSIZED_LINE_HEAD_BYTES) break;
head.push(part.subarray(0, OVERSIZED_LINE_HEAD_BYTES - bytes));
bytes += head[head.length - 1].byteLength;
}
return Buffer.concat(head).toString("utf8");
}
/**
* Admits raw UTF-8 bytes before retaining, decoding or joining a complete line.
* An oversized line is reported with its head and skipped up to its newline;
* the owner closes the reader if the stream cannot continue without it.
*/
export class ProviderLineReader {
private parts: Buffer[] = [];
private bytes = 0;
private skippingOversizedLine = false;
private stopped = false;
constructor(
@@ -31,11 +54,20 @@ export class ProviderLineReader {
while (start < buffer.byteLength && !this.stopped) {
const newline = buffer.indexOf(10, start);
const end = newline === -1 ? buffer.byteLength : newline;
if (this.skippingOversizedLine) {
if (newline === -1) return;
this.skippingOversizedLine = false;
start = newline + 1;
continue;
}
const length = end - start;
if (length > this.limitBytes - this.bytes) {
this.close();
this.onLimit(new ProviderStreamLimitError(this.limitBytes));
return;
const head = readLineHead([...this.parts, buffer.subarray(start, end)]);
this.parts = [];
this.bytes = 0;
this.skippingOversizedLine = true;
this.onLimit(new ProviderStreamLimitError(this.limitBytes, head));
continue;
}
if (length) this.parts.push(buffer.subarray(start, end));
this.bytes += length;
@@ -150,7 +182,10 @@ export class JsonlFrameDecoder {
(line) => {
if (line.trim()) this.consumeLine(line);
},
(error) => this.drop("line-too-large", error),
(error) => {
this.lines.close();
this.drop("line-too-large", error);
},
);
}
@@ -0,0 +1,8 @@
{"type": "session", "version": 3, "id": "e56a0006-f421-45be-9aef-e4019db1cf24", "timestamp": "2026-10-21T13:05:57.384Z", "cwd": "/tmp/paseo-edxeth-fixture/lean/workspace", "name": "[marker] Fixture marker verification", "parentSession": "/tmp/paseo-edxeth-fixture/lean/sessions/2026-10-21T13-05-53-085Z_01a15e91-73bd-7282-82c6-d3974711a941.jsonl"}
{"type": "custom", "customType": "pi-subagents_launch_metadata", "data": {"version": 1, "timestamp": "2026-10-21T13:05:57.385Z", "name": "fixture-marker", "title": "Fixture marker verification", "sessionTitle": "[marker] Fixture marker verification", "agent": "marker", "mode": "background", "sessionMode": "lineage-only", "autoExit": true, "parentClosePolicy": "terminate", "reportContextUsage": true, "async": true, "model": "openai-codex/gpt-5.6-sol", "thinking": "high", "modelRef": "openai-codex/gpt-5.6-sol:high", "definitionModel": "openai-codex/gpt-5.6-sol", "definitionThinking": "high", "allowModelOverride": true, "modelSource": "agent", "tools": "read", "denyTools": ["subagent", "subagent_resume", "subagent_kill"], "spawnableAgents": [], "spawnBudget": null, "extensions": [], "noContextFiles": false, "inheritAppendSystem": false, "noSession": false, "trustProject": false, "agentConfigDir": "/tmp/paseo-edxeth-fixture/lean/agent", "cwd": "/tmp/paseo-edxeth-fixture/lean/workspace", "boundarySystemPrompt": false}, "id": "57355eeb", "parentId": null, "timestamp": "2026-10-21T13:05:57.386Z"}
{"type": "session_info", "id": "847415e4", "parentId": "57355eeb", "timestamp": "2026-10-21T13:05:57.566Z", "name": "[marker] Fixture marker verification"}
{"type": "model_change", "id": "d4ad59be", "parentId": "847415e4", "timestamp": "2026-10-21T13:05:57.672Z", "provider": "openai-codex", "modelId": "gpt-5.6-sol"}
{"type": "thinking_level_change", "id": "7f6bf9d2", "parentId": "d4ad59be", "timestamp": "2026-10-21T13:05:57.672Z", "thinkingLevel": "high"}
{"type": "message", "id": "737b762e", "parentId": "6b436446", "timestamp": "2026-10-21T13:05:57.679Z", "message": {"role": "user", "content": [{"type": "text", "text": "<file name=\"/home/pi-user/.pi/history/workspace/artifacts/01a15e91-73bd-7282-82c6-d3974711a941/context/fixture-marker-2026-10-21T13-05-57.md\">\n\n\nReturn only the exact marker supplied by the user. Do not use tools.\n\nComplete your task autonomously.\n\nReturn only PASEO_EDXETH_LEAN_OK\n\nYour FINAL assistant message should summarize what you accomplished.\n</file>\n"}], "timestamp": 1792587957678}}
{"type": "message", "id": "100b110d", "parentId": "737b762e", "timestamp": "2026-10-21T13:06:02.050Z", "message": {"role": "assistant", "content": [{"type": "text", "text": "PASEO_EDXETH_LEAN_OK"}], "api": "openai-codex-responses", "provider": "openai-codex", "model": "gpt-5.6-sol", "usage": {"input": 1713, "output": 13, "cacheRead": 0, "cacheWrite": 0, "reasoning": 0, "totalTokens": 1726, "cost": {"input": 0.006852, "output": 0.00026000000000000003, "cacheRead": 0, "cacheWrite": 0, "total": 0.007111999999999999}}, "stopReason": "stop", "timestamp": 1792587957684, "responseId": "resp_0bc8bfaee079148a016ad8b8b7edb08191924676ed8dcfd447", "rawStopReason": "completed"}}
{"type": "custom", "customType": "pi-subagent-completion", "data": {"reason": "normal"}, "id": "80bbd3bb", "parentId": "100b110d", "timestamp": "2026-10-21T13:06:02.054Z"}
@@ -0,0 +1,263 @@
{
"provenance": {
"package": "Zeno2019/pi-subagents-lean",
"version": "2.9.2-lean.1",
"sourceCommit": "e10fe855808abcae193a205ea98f8ccd70d25620",
"piVersion": "0.87.1",
"captureDate": "2026-10-21"
},
"events": [
{
"type": "tool_execution_start",
"toolCallId": "call_f28b261de3c6440b953f0f56c3fec7d0|fc_0d35cf51b3ed48b9016ad8b8b4a63c81918a55903d0a4f12cb",
"toolName": "subagent",
"args": {
"agent": "marker",
"name": "fixture-marker",
"title": "Fixture marker verification",
"task": "Return only PASEO_EDXETH_LEAN_OK"
}
},
{
"type": "tool_execution_end",
"toolCallId": "call_f28b261de3c6440b953f0f56c3fec7d0|fc_0d35cf51b3ed48b9016ad8b8b4a63c81918a55903d0a4f12cb",
"toolName": "subagent",
"result": {
"content": [
{
"type": "text",
"text": "Sub-agent \"fixture-marker\" launched async with id 1bfdf3b3. Results will be delivered automatically as a steer message when it finishes. Use this exact id if you need to resume or stop this child.\n\nActivity: /home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/activity.jsonl\nRun evidence: /home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/result.json\nRead these on demand for progress or failure diagnosis; activity is evidence, not a completion verdict."
}
],
"details": {
"evidence": {
"runId": "1bfdf3b3",
"cwd": "/tmp/paseo-edxeth-fixture/lean/workspace",
"activityFile": "/home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/activity.jsonl",
"resultFile": "/home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/result.json"
},
"id": "1bfdf3b3",
"name": "fixture-marker",
"title": "Fixture marker verification",
"task": "Return only PASEO_EDXETH_LEAN_OK",
"agent": "marker",
"sessionFile": "/tmp/paseo-edxeth-fixture/background/child-session.jsonl",
"noSession": false,
"status": "started",
"mode": "background",
"deliveryState": "detached",
"parentClosePolicy": "terminate",
"async": true,
"autoExit": true
}
},
"isError": false
},
{
"type": "message_end",
"message": {
"role": "custom",
"customType": "subagent_result",
"content": "Sub-agent \"fixture-marker\" completed (7s).\n\nPASEO_EDXETH_LEAN_OK\n\nSession: /tmp/paseo-edxeth-fixture/background/child-session.jsonl\nResume: pi --session /tmp/paseo-edxeth-fixture/background/child-session.jsonl\n\nSub-agent context: 2K/372K tokens (0%) used at finish.\n\nActivity: /home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/activity.jsonl\nRun evidence: /home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/result.json\nRead these on demand for progress or failure diagnosis; activity is evidence, not a completion verdict.",
"display": true,
"details": {
"evidence": {
"runId": "1bfdf3b3",
"cwd": "/tmp/paseo-edxeth-fixture/lean/workspace",
"activityFile": "/home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/activity.jsonl",
"resultFile": "/home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/result.json",
"finalized": true
},
"id": "1bfdf3b3",
"name": "fixture-marker",
"task": "Return only PASEO_EDXETH_LEAN_OK",
"agent": "marker",
"mode": "background",
"status": "completed",
"deliveryState": "detached",
"parentClosePolicy": "terminate",
"async": true,
"exitCode": 0,
"elapsed": 7,
"outputTokens": 13,
"contextTokens": 1775,
"contextWindow": 372000,
"sessionFile": "/tmp/paseo-edxeth-fixture/background/child-session.jsonl"
},
"timestamp": 1792587964414
}
}
],
"messages": [
{
"role": "assistant",
"content": [
{
"type": "toolCall",
"id": "call_f28b261de3c6440b953f0f56c3fec7d0|fc_0d35cf51b3ed48b9016ad8b8b4a63c81918a55903d0a4f12cb",
"name": "subagent",
"arguments": {
"agent": "marker",
"name": "fixture-marker",
"title": "Fixture marker verification",
"task": "Return only PASEO_EDXETH_LEAN_OK"
}
}
],
"api": "openai-codex-responses",
"provider": "openai-codex",
"model": "gpt-5.6-sol",
"usage": {
"input": 2020,
"output": 43,
"cacheRead": 0,
"cacheWrite": 0,
"reasoning": 0,
"totalTokens": 2063,
"cost": {
"input": 0.00808,
"output": 0.0008600000000000001,
"cacheRead": 0,
"cacheWrite": 0,
"total": 0.00894
}
},
"stopReason": "toolUse",
"timestamp": 1792587953256,
"responseId": "resp_0d35cf51b3ed48b9016ad8b8b3698481918f82962b6eaf2788",
"rawStopReason": "completed"
},
{
"role": "toolResult",
"toolCallId": "call_f28b261de3c6440b953f0f56c3fec7d0|fc_0d35cf51b3ed48b9016ad8b8b4a63c81918a55903d0a4f12cb",
"toolName": "subagent",
"content": [
{
"type": "text",
"text": "Sub-agent \"fixture-marker\" launched async with id 1bfdf3b3. Results will be delivered automatically as a steer message when it finishes. Use this exact id if you need to resume or stop this child.\n\nActivity: /home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/activity.jsonl\nRun evidence: /home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/result.json\nRead these on demand for progress or failure diagnosis; activity is evidence, not a completion verdict."
}
],
"details": {
"evidence": {
"runId": "1bfdf3b3",
"cwd": "/tmp/paseo-edxeth-fixture/lean/workspace",
"activityFile": "/home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/activity.jsonl",
"resultFile": "/home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/result.json",
"finalized": true
},
"id": "1bfdf3b3",
"name": "fixture-marker",
"title": "Fixture marker verification",
"task": "Return only PASEO_EDXETH_LEAN_OK",
"agent": "marker",
"sessionFile": "/tmp/paseo-edxeth-fixture/background/child-session.jsonl",
"noSession": false,
"status": "started",
"mode": "background",
"deliveryState": "detached",
"parentClosePolicy": "terminate",
"async": true,
"autoExit": true
},
"isError": false,
"timestamp": 1792587957399
},
{
"role": "assistant",
"content": [
{
"type": "thinking",
"thinking": "**Identifying need for await keyword**"
},
{
"type": "text",
"text": "DONE"
}
],
"api": "openai-codex-responses",
"provider": "openai-codex",
"model": "gpt-5.6-sol",
"usage": {
"input": 2258,
"output": 18,
"cacheRead": 0,
"cacheWrite": 0,
"reasoning": 11,
"totalTokens": 2276,
"cost": {
"input": 0.009032,
"output": 0.00036,
"cacheRead": 0,
"cacheWrite": 0,
"total": 0.009392
}
},
"stopReason": "stop",
"timestamp": 1792587957402,
"responseId": "resp_0d35cf51b3ed48b9016ad8b8b602108191a8e8f7fd77fbe5b5",
"rawStopReason": "completed"
},
{
"role": "custom",
"customType": "subagent_result",
"content": "Sub-agent \"fixture-marker\" completed (7s).\n\nPASEO_EDXETH_LEAN_OK\n\nSession: /tmp/paseo-edxeth-fixture/background/child-session.jsonl\nResume: pi --session /tmp/paseo-edxeth-fixture/background/child-session.jsonl\n\nSub-agent context: 2K/372K tokens (0%) used at finish.\n\nActivity: /home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/activity.jsonl\nRun evidence: /home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/result.json\nRead these on demand for progress or failure diagnosis; activity is evidence, not a completion verdict.",
"display": true,
"details": {
"evidence": {
"runId": "1bfdf3b3",
"cwd": "/tmp/paseo-edxeth-fixture/lean/workspace",
"activityFile": "/home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/activity.jsonl",
"resultFile": "/home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/result.json",
"finalized": true
},
"id": "1bfdf3b3",
"name": "fixture-marker",
"task": "Return only PASEO_EDXETH_LEAN_OK",
"agent": "marker",
"mode": "background",
"status": "completed",
"deliveryState": "detached",
"parentClosePolicy": "terminate",
"async": true,
"exitCode": 0,
"elapsed": 7,
"outputTokens": 13,
"contextTokens": 1775,
"contextWindow": 372000,
"sessionFile": "/tmp/paseo-edxeth-fixture/background/child-session.jsonl"
},
"timestamp": 1792587964414
},
{
"role": "assistant",
"content": [
{
"type": "text",
"text": "DONE"
}
],
"api": "openai-codex-responses",
"provider": "openai-codex",
"model": "gpt-5.6-sol",
"usage": {
"input": 389,
"output": 5,
"cacheRead": 2176,
"cacheWrite": 0,
"reasoning": 0,
"totalTokens": 2570,
"cost": {
"input": 0.0015559999999999999,
"output": 0.0001,
"cacheRead": 0.0008704000000000001,
"cacheWrite": 0,
"total": 0.0025264
}
},
"stopReason": "stop",
"timestamp": 1792587964417,
"responseId": "resp_0d35cf51b3ed48b9016ad8b8bd0c548191bddd1fe3410a1c82",
"rawStopReason": "completed"
}
]
}
@@ -0,0 +1,8 @@
{"type": "session", "version": 3, "id": "afbea6e0-7858-4ecf-8ef1-e26af9b7d6e0", "timestamp": "2026-10-21T13:05:17.628Z", "cwd": "/tmp/paseo-edxeth-fixture/upstream/workspace", "name": "[marker] Fixture marker verification", "parentSession": "/tmp/paseo-edxeth-fixture/upstream/sessions/2026-10-21T13-05-12-212Z_01a15e90-d414-7243-a701-4eb4b0bec880.jsonl"}
{"type": "custom", "customType": "pi-subagents_launch_metadata", "data": {"version": 1, "timestamp": "2026-10-21T13:05:17.628Z", "name": "fixture-marker", "title": "Fixture marker verification", "sessionTitle": "[marker] Fixture marker verification", "agent": "marker", "mode": "background", "sessionMode": "lineage-only", "autoExit": true, "parentClosePolicy": "terminate", "reportContextUsage": true, "async": false, "model": "openai-codex/gpt-5.6-sol", "thinking": "high", "modelRef": "openai-codex/gpt-5.6-sol:high", "definitionModel": "openai-codex/gpt-5.6-sol", "definitionThinking": "high", "allowModelOverride": true, "modelSource": "agent", "tools": "read", "denyTools": ["subagent", "subagent_resume", "subagent_kill"], "spawnableAgents": [], "spawnBudget": null, "extensions": [], "noContextFiles": false, "inheritAppendSystem": false, "noSession": false, "trustProject": false, "agentConfigDir": "/tmp/paseo-edxeth-fixture/upstream/agent", "cwd": "/tmp/paseo-edxeth-fixture/upstream/workspace", "boundarySystemPrompt": false}, "id": "3f7d3c3f", "parentId": null, "timestamp": "2026-10-21T13:05:17.629Z"}
{"type": "session_info", "id": "6e198450", "parentId": "3f7d3c3f", "timestamp": "2026-10-21T13:05:17.825Z", "name": "[marker] Fixture marker verification"}
{"type": "model_change", "id": "94bf4c0b", "parentId": "6e198450", "timestamp": "2026-10-21T13:05:17.962Z", "provider": "openai-codex", "modelId": "gpt-5.6-sol"}
{"type": "thinking_level_change", "id": "57cc6428", "parentId": "94bf4c0b", "timestamp": "2026-10-21T13:05:17.962Z", "thinkingLevel": "high"}
{"type": "message", "id": "a9e47f08", "parentId": "8c186624", "timestamp": "2026-10-21T13:05:17.970Z", "message": {"role": "user", "content": [{"type": "text", "text": "<file name=\"/home/pi-user/.pi/history/workspace/artifacts/01a15e90-d414-7243-a701-4eb4b0bec880/context/fixture-marker-2026-10-21T13-05-17.md\">\n\n\nReturn only the exact marker supplied by the user. Do not use tools.\n\nComplete your task autonomously.\n\nReturn only PASEO_EDXETH_UPSTREAM_OK\n\nYour FINAL assistant message should summarize what you accomplished.\n</file>\n"}], "timestamp": 1792587917968}}
{"type": "message", "id": "bc221a13", "parentId": "a9e47f08", "timestamp": "2026-10-21T13:05:23.476Z", "message": {"role": "assistant", "content": [{"type": "thinking", "thinking": "**Following user file marker exactly**"}, {"type": "text", "text": "PASEO_EDXETH_UPSTREAM_OK"}], "api": "openai-codex-responses", "provider": "openai-codex", "model": "gpt-5.6-sol", "usage": {"input": 1673, "output": 49, "cacheRead": 0, "cacheWrite": 0, "reasoning": 34, "totalTokens": 1722, "cost": {"input": 0.006692, "output": 0.0009800000000000002, "cacheRead": 0, "cacheWrite": 0, "total": 0.007672}}, "stopReason": "stop", "timestamp": 1792587917974, "responseId": "resp_09445c1733d532e7016ad8b8909c908191ba803c059497945e", "rawStopReason": "completed"}}
{"type": "custom", "customType": "pi-subagent-completion", "data": {"reason": "normal"}, "id": "832fc8c9", "parentId": "bc221a13", "timestamp": "2026-10-21T13:05:23.477Z"}
@@ -0,0 +1,156 @@
{
"provenance": {
"package": "edxeth/pi-subagents",
"version": "2.9.2",
"sourceCommit": "cf6dbf41c17986f3882e6804a68e8fb8282d25e6",
"piVersion": "0.87.1",
"captureDate": "2026-10-21"
},
"events": [
{
"type": "tool_execution_start",
"toolCallId": "call_0bc6fc2213cd49aa92950947bde5dd8a|fc_0a0ca4aa78cd296a016ad8b88d018481919eadbf09eb191a1a",
"toolName": "subagent",
"args": {
"agent": "marker",
"name": "fixture-marker",
"title": "Fixture marker verification",
"task": "Return only PASEO_EDXETH_UPSTREAM_OK"
}
},
{
"type": "tool_execution_end",
"toolCallId": "call_0bc6fc2213cd49aa92950947bde5dd8a|fc_0a0ca4aa78cd296a016ad8b88d018481919eadbf09eb191a1a",
"toolName": "subagent",
"result": {
"content": [
{
"type": "text",
"text": "Sub-agent \"fixture-marker\" completed (7s).\n\nPASEO_EDXETH_UPSTREAM_OK\n\nSession: /tmp/paseo-edxeth-fixture/foreground/child-session.jsonl\nResume: pi --session /tmp/paseo-edxeth-fixture/foreground/child-session.jsonl\n\nSub-agent context: 2K/372K tokens (0%) used at finish."
}
],
"details": {
"id": "dd7f94e8",
"name": "fixture-marker",
"agent": "marker",
"status": "completed",
"mode": "background",
"parentClosePolicy": "terminate",
"deliveryState": "awaited",
"async": false,
"autoExit": true,
"exitCode": 0,
"elapsed": 7,
"outputTokens": 49,
"contextTokens": 1777,
"contextWindow": 372000,
"summary": "PASEO_EDXETH_UPSTREAM_OK",
"sessionFile": "/tmp/paseo-edxeth-fixture/foreground/child-session.jsonl"
}
},
"isError": false
}
],
"messages": [
{
"role": "assistant",
"content": [
{
"type": "toolCall",
"id": "call_0bc6fc2213cd49aa92950947bde5dd8a|fc_0a0ca4aa78cd296a016ad8b88d018481919eadbf09eb191a1a",
"name": "subagent",
"arguments": {
"agent": "marker",
"name": "fixture-marker",
"title": "Fixture marker verification",
"task": "Return only PASEO_EDXETH_UPSTREAM_OK"
}
}
],
"api": "openai-codex-responses",
"provider": "openai-codex",
"model": "gpt-5.6-sol",
"usage": {
"input": 3256,
"output": 43,
"cacheRead": 0,
"cacheWrite": 0,
"reasoning": 0,
"totalTokens": 3299,
"cost": {
"input": 0.013023999999999999,
"output": 0.0008600000000000001,
"cacheRead": 0,
"cacheWrite": 0,
"total": 0.013883999999999999
}
},
"stopReason": "toolUse",
"timestamp": 1792587912428,
"responseId": "resp_0a0ca4aa78cd296a016ad8b88bd52881918cbb9b8326b3060a",
"rawStopReason": "completed"
},
{
"role": "toolResult",
"toolCallId": "call_0bc6fc2213cd49aa92950947bde5dd8a|fc_0a0ca4aa78cd296a016ad8b88d018481919eadbf09eb191a1a",
"toolName": "subagent",
"content": [
{
"type": "text",
"text": "Sub-agent \"fixture-marker\" completed (7s).\n\nPASEO_EDXETH_UPSTREAM_OK\n\nSession: /tmp/paseo-edxeth-fixture/foreground/child-session.jsonl\nResume: pi --session /tmp/paseo-edxeth-fixture/foreground/child-session.jsonl\n\nSub-agent context: 2K/372K tokens (0%) used at finish."
}
],
"details": {
"id": "dd7f94e8",
"name": "fixture-marker",
"agent": "marker",
"status": "completed",
"mode": "background",
"parentClosePolicy": "terminate",
"deliveryState": "awaited",
"async": false,
"autoExit": true,
"exitCode": 0,
"elapsed": 7,
"outputTokens": 49,
"contextTokens": 1777,
"contextWindow": 372000,
"summary": "PASEO_EDXETH_UPSTREAM_OK",
"sessionFile": "/tmp/paseo-edxeth-fixture/foreground/child-session.jsonl"
},
"isError": false,
"timestamp": 1792587924671
},
{
"role": "assistant",
"content": [
{
"type": "text",
"text": "DONE"
}
],
"api": "openai-codex-responses",
"provider": "openai-codex",
"model": "gpt-5.6-sol",
"usage": {
"input": 388,
"output": 5,
"cacheRead": 3072,
"cacheWrite": 0,
"reasoning": 0,
"totalTokens": 3465,
"cost": {
"input": 0.001552,
"output": 0.0001,
"cacheRead": 0.0012288000000000002,
"cacheWrite": 0,
"total": 0.0028808
}
},
"stopReason": "stop",
"timestamp": 1792587924675,
"responseId": "resp_0a0ca4aa78cd296a016ad8b896af2c81919c0ef586dd50f2f0",
"rawStopReason": "completed"
}
]
}
@@ -0,0 +1,336 @@
import { describe, expect, test } from "vitest";
import { fileURLToPath } from "node:url";
import { createPiExtensionHost } from "../index.js";
import { readSubagentFixture, verifySubagentFixture } from "../subagent-fixture-test.js";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { edxethPiSubagents } from "./index.js";
import { streamPiHistory } from "../../history-mapper.js";
import { ProviderSubagentStore } from "../../../../provider-subagents/store.js";
const args = {
agent: "scout",
name: "status-scout",
title: "Check child status",
task: "Return the completion marker",
};
describe("edxeth/pi-subagents adapter", () => {
test.each(["tool", "notification"])("ends a child that requested help via %s", (delivery) => {
const host = createPiExtensionHost();
const call = { callId: "spawn-1", toolName: "subagent", args, status: "completed" as const };
host.mapToolCall({
...call,
result: { details: { id: "run-1", name: args.name, status: "started" } },
});
const details = { id: "run-1", name: args.name, message: "Please clarify the task" };
const result =
delivery === "tool"
? host.mapToolCall({ ...call, result: { details: { ...details, status: "pinged" } } })
: host.mapCustomMessage({
role: "custom",
customType: "subagent_ping",
content: "Child requested help and exited",
details,
});
expect(result?.subagents).toEqual([
expect.objectContaining({
type: "upsert",
id: "spawn-1",
status: "canceled",
subtitle: "Needs help",
}),
]);
});
test("leaves unrelated tools and other subagent protocols to their adapters", () => {
const host = createPiExtensionHost(undefined, [edxethPiSubagents]);
const call = { callId: "other", toolName: "subagent", args, status: "completed" as const };
for (const details of [null, {}, { mode: "single", results: [] }, { id: "run-1" }]) {
expect(host.mapToolCall({ ...call, result: { details } })).toBeUndefined();
}
expect(
host.mapToolCall({
...call,
toolName: "unrelated",
result: { details: { id: "run-1", name: args.name, status: "completed" } },
}),
).toBeUndefined();
for (const details of [{ id: "run-1" }, { id: "run-1", status: "unknown" }]) {
expect(
host.mapCustomMessage({
role: "custom",
customType: "subagent_result",
content: "Unrecognized result",
details,
}),
).toBeUndefined();
}
});
test("waits for cancellation after a kill acknowledgement reports stopping", () => {
const host = createPiExtensionHost();
host.mapToolCall({
callId: "spawn-1",
toolName: "subagent",
args,
status: "completed",
result: { details: { id: "run-1", name: args.name, status: "started" } },
});
const stopping = host.mapToolCall({
callId: "kill-1",
toolName: "subagent_kill",
args: { id: "run-1" },
status: "completed",
result: { details: { id: "run-1", name: args.name, status: "stopping" } },
});
expect(stopping?.subagents ?? []).toEqual([]);
expect(
host.mapCustomMessage({
role: "custom",
customType: "subagent_result",
content: "Cancelled",
details: { id: "run-1", status: "cancelled" },
})?.subagents,
).toEqual([{ type: "upsert", id: "spawn-1", status: "canceled" }]);
});
test("preserves a completion that arrives before its launch acknowledgement", () => {
const host = createPiExtensionHost();
const early = host.mapCustomMessage({
role: "custom",
customType: "subagent_result",
content: "Already done",
details: { id: "fast-run", status: "completed" },
});
expect(early?.subagents ?? []).toEqual([]);
const started = host.mapToolCall({
callId: "fast-call",
toolName: "subagent",
args,
status: "completed",
result: {
details: { id: "fast-run", name: args.name, agent: args.agent, status: "started" },
},
});
expect(started?.subagents).toEqual([
{
type: "upsert",
id: "fast-call",
title: "scout",
description: args.task,
toolCallId: "fast-call",
status: "completed",
},
]);
});
test.each([
{ details: { status: "completed" }, expected: "completed" },
{ details: { status: "failed" }, expected: "failed" },
{ details: { status: "cancelled" }, expected: "canceled" },
{ details: { error: "Child watcher exited unexpectedly" }, expected: "failed" },
])("maps completion $details to $expected", ({ details, expected }) => {
const host = createPiExtensionHost();
host.mapToolCall({
callId: "spawn-1",
toolName: "subagent",
args,
status: "completed",
result: { details: { id: "run-1", name: args.name, status: "started" } },
});
expect(
host.mapCustomMessage({
role: "custom",
customType: "subagent_result",
content: "Child result",
details: { id: "run-1", ...details },
})?.subagents,
).toEqual([{ type: "upsert", id: "spawn-1", status: expected }]);
});
test("a resume gets its own run and reads the retained child session", async () => {
const dir = await mkdtemp(join(tmpdir(), "paseo-edxeth-resume-"));
const file = join(dir, "child.jsonl");
const host = createPiExtensionHost();
try {
await writeFile(
file,
JSON.stringify({
type: "message",
message: { role: "assistant", content: [{ type: "text", text: "RESUMED" }] },
}) + "\n",
);
const original = host.mapToolCall({
callId: "spawn-1",
toolName: "subagent",
args,
status: "completed",
result: {
details: { id: "run-1", name: args.name, status: "completed", sessionFile: file },
},
});
await original?.hydration;
const resumed = host.mapToolCall({
callId: "resume-1",
toolName: "subagent_resume",
args: { sessionFile: file, task: "Continue" },
status: "completed",
result: {
details: { id: "run-2", name: args.name, status: "started", sessionFile: file },
},
});
expect(resumed?.childSessions).toEqual([{ id: "resume-1", file }]);
expect(await resumed?.hydration).toEqual([
{
type: "provider_subagent",
provider: "pi",
event: {
type: "timeline",
id: "resume-1",
item: expect.objectContaining({ type: "assistant_message", text: "RESUMED" }),
},
},
]);
expect(
host.mapCustomMessage({
role: "custom",
customType: "subagent_result",
content: "Resumed run complete",
details: { id: "run-2", status: "completed", sessionFile: file },
})?.subagents,
).toEqual([{ type: "upsert", id: "resume-1", status: "completed" }]);
} finally {
host.close();
await rm(dir, { recursive: true, force: true });
}
});
test("settles each batched child by run id without creating a parent-call card", () => {
const host = createPiExtensionHost();
const result = host.mapToolCall({
callId: "batch-1",
toolName: "subagent",
args: { children: [args, { ...args, agent: "reviewer", name: "status-reviewer" }] },
status: "completed",
result: {
details: {
status: "batch",
children: [
{ id: "run-1", ...args, mode: "background", status: "completed" },
{ id: "run-2", name: "status-reviewer", agent: "reviewer", status: "started" },
],
},
},
});
expect(result?.subagents).toEqual([
{
type: "upsert",
id: "batch-1:0",
title: "scout",
description: args.task,
toolCallId: "batch-1",
status: "completed",
},
{
type: "upsert",
id: "batch-1:1",
title: "reviewer",
description: undefined,
toolCallId: "batch-1",
status: "running",
},
]);
expect(
host.mapCustomMessage({
role: "custom",
customType: "subagent_result",
content: "Done",
details: { id: "run-2", status: "completed" },
})?.subagents,
).toEqual([{ type: "upsert", id: "batch-1:1", status: "completed" }]);
});
test.each([
{ mode: "foreground", statuses: ["running", "completed"] },
{ mode: "background", statuses: ["running", "running", "completed"] },
])("settles the captured $mode run live and on replay", async ({ mode, statuses }) => {
const fixture = readSubagentFixture(new URL(`./fixtures/${mode}.json`, import.meta.url), {
from: `/tmp/paseo-edxeth-fixture/${mode}/child-session.jsonl`,
to: fileURLToPath(new URL(`./fixtures/${mode}-child-session.jsonl`, import.meta.url)),
});
const events = await verifySubagentFixture(fixture);
const updates = events.flatMap(({ event }) => (event.type === "upsert" ? [event] : []));
expect(updates.map((event) => event.status)).toEqual(statuses);
expect(new Set(updates.map((event) => event.id)).size).toBe(1);
expect(events.some(({ event }) => event.type === "timeline")).toBe(true);
});
test.each(["foreground", "background"])(
"restores a completed %s child and its transcript to the consumer store",
async (mode) => {
const fixture = readSubagentFixture(new URL(`./fixtures/${mode}.json`, import.meta.url), {
from: `/tmp/paseo-edxeth-fixture/${mode}/child-session.jsonl`,
to: fileURLToPath(new URL(`./fixtures/${mode}-child-session.jsonl`, import.meta.url)),
});
const store = new ProviderSubagentStore();
for await (const event of streamPiHistory("pi", fixture.messages)) {
if (event.type === "provider_subagent") store.apply("parent", "pi", event.event);
}
const children = store.list("parent");
expect(children).toEqual([expect.objectContaining({ status: "completed", title: "marker" })]);
const transcript = store.fetchTimeline("parent", children[0]!.id);
expect(transcript.rows).toContainEqual(
expect.objectContaining({
item: expect.objectContaining({
type: "assistant_message",
text: mode === "foreground" ? "PASEO_EDXETH_UPSTREAM_OK" : "PASEO_EDXETH_LEAN_OK",
}),
}),
);
},
);
test("settles a synchronous result on the card created when the tool started", () => {
const host = createPiExtensionHost();
const call = { callId: "spawn-1", toolName: "subagent", args };
const start = host.mapToolCall({ ...call, status: "running", result: null });
expect(start?.subagents).toEqual([
{
type: "upsert",
id: "spawn-1",
title: "scout",
description: args.task,
toolCallId: "spawn-1",
status: "running",
},
]);
const end = host.mapToolCall({
...call,
status: "completed",
result: {
content: [{ type: "text", text: "DONE" }],
details: {
id: "run-1",
name: args.name,
agent: args.agent,
mode: "background",
status: "completed",
deliveryState: "awaited",
exitCode: 0,
},
},
});
expect(end?.subagents).toEqual([
{
type: "upsert",
id: "spawn-1",
title: "scout",
description: args.task,
toolCallId: "spawn-1",
status: "completed",
},
]);
});
});
@@ -0,0 +1,119 @@
import { z } from "zod";
import { extractTextFromToolResult } from "../../tool-call-mapper.js";
import type { PiChildSessionFile, PiExtension } from "../contract.js";
import type { ProviderSubagentInputEvent } from "../../../../provider-subagents/store.js";
const Run = z
.object({
id: z.string().trim().min(1),
name: z.string().trim().min(1),
agent: z.string().trim().min(1).optional(),
task: z.string().optional(),
status: z.enum(["started", "completed", "failed", "cancelled", "pinged"]),
sessionFile: z.string().trim().min(1).optional(),
})
.passthrough();
const Args = z.object({ task: z.string().optional() }).passthrough();
const Batch = z.object({ status: z.enum(["started", "batch"]), children: z.array(Run).min(1) });
const Completion = z
.object({
id: z.string().trim().min(1),
status: z.enum(["completed", "failed", "cancelled", "pinged"]).optional(),
error: z.string().optional(),
sessionFile: z.string().trim().min(1).optional(),
})
.passthrough();
const Ping = Completion.extend({ message: z.string() });
function childStatus(status: z.infer<typeof Run>["status"]) {
if (status === "started") return "running";
// A ping ends the child process and needs a new resume run. It is not success.
if (status === "cancelled" || status === "pinged") return "canceled";
return status;
}
export const edxethPiSubagents: PiExtension = {
id: "edxeth/pi-subagents",
createSession: () => {
const callsByRun = new Map<string, string>();
const completions = new Map<string, z.infer<typeof Completion>>();
const readSessions = new Map<string, string>();
const childSession = (id: string, file: string | undefined): PiChildSessionFile[] => {
if (!file || readSessions.get(id) === file) return [];
readSessions.set(id, file);
return [{ id, file }];
};
const completeRun = (result: z.infer<typeof Completion>) => {
const status = result.status ?? (result.error === undefined ? undefined : "failed");
if (!status) return undefined;
// A fast child can finish before the tool returns its run id. Keep the
// terminal outcome so a later "started" acknowledgement cannot regress it.
completions.set(result.id, { ...result, status });
const id = callsByRun.get(result.id);
if (!id) return undefined;
const subagents: ProviderSubagentInputEvent[] = [
{
type: "upsert",
id,
status: childStatus(status),
...(status === "pinged" ? { subtitle: "Needs help" } : {}),
},
];
return { subagents, childSessions: childSession(id, result.sessionFile) };
};
return {
mapToolCall(call) {
const isLaunch = call.toolName === "subagent" || call.toolName === "subagent_resume";
if (!isLaunch || call.status === "running") return undefined;
const details = typeof call.result === "object" ? call.result?.details : null;
const single = Run.safeParse(details);
let runs: z.infer<typeof Run>[];
if (single.success) {
runs = [single.data];
} else {
const batch = Batch.safeParse(details);
if (!batch.success) return undefined;
runs = batch.data.children;
}
const args = Args.safeParse(call.args);
const description = args.success ? args.data.task : undefined;
const subagents: ProviderSubagentInputEvent[] = [];
const childSessions: PiChildSessionFile[] = [];
for (const [index, run] of runs.entries()) {
const id = runs.length === 1 ? call.callId : `${call.callId}:${index}`;
const completion = completions.get(run.id);
const status = completion?.status ?? run.status;
callsByRun.set(run.id, id);
subagents.push({
type: "upsert",
id,
title: run.agent ?? run.name,
description: run.task ?? description,
toolCallId: call.callId,
status: childStatus(status),
...(status === "pinged" ? { subtitle: "Needs help" } : {}),
});
childSessions.push(...childSession(id, completion?.sessionFile ?? run.sessionFile));
}
return {
detail: {
type: "sub_agent",
description,
log: extractTextFromToolResult(call.result)?.trim() ?? "",
},
subagents,
childSessions,
};
},
mapCustomMessage(message) {
if (message.customType === "subagent_ping") {
const ping = Ping.safeParse(message.details);
return ping.success ? completeRun({ ...ping.data, status: "pinged" }) : undefined;
}
if (message.customType !== "subagent_result") return undefined;
const parsed = Completion.safeParse(message.details);
return parsed.success ? completeRun(parsed.data) : undefined;
},
};
},
};
@@ -1,5 +1,6 @@
import { piAskUser } from "./pi-ask-user/index.js";
import { piSubagents } from "./pi-subagents/index.js";
import { edxethPiSubagents } from "./edxeth-pi-subagents/index.js";
import { piMcpAdapter } from "./pi-mcp-adapter/index.js";
import { rpivTodo } from "./rpiv-todo/index.js";
import { piExampleTodo } from "./pi-example-todo/index.js";
@@ -10,6 +11,7 @@ import { rpivAskUserQuestion } from "./rpiv-ask-user-question/index.js";
export const piExtensions = [
piAskUser, // Dialog correlation
edxethPiSubagents, // Structured results before the generic subagent tool matcher
piSubagents, // Delegation calls
tintinwebPiSubagents,
gotgenesPiSubagents,
@@ -184,17 +184,24 @@ test("repeated content is reused without rewriting the attachment", async () =>
test("64 MiB materialization leaves the event loop responsive and preserves bytes", async () => {
const bytes = Buffer.alloc(64 * 1024 * 1024, 37);
const data = bytes.toString("base64");
let previous = performance.now();
// Main-thread CPU time between ticks bounds the decode and hash work done on the loop;
// wall-clock gaps on shared CI runners also count preemption and timer granularity.
// File writes stay on fs/promises, so they never block the loop.
const threadCpuMs = () => {
const { user, system } = process.threadCpuUsage();
return (user + system) / 1000;
};
let previous = threadCpuMs();
let longest = 0;
const probe = setInterval(() => {
const now = performance.now();
const now = threadCpuMs();
longest = Math.max(longest, now - previous);
previous = now;
}, 1);
try {
const result = await materializeProviderImage({ data, mimeType: "image/png" });
await new Promise((resolve) => setTimeout(resolve, 5));
console.log(`64 MiB attachment longest loop gap: ${longest.toFixed(2)} ms`);
console.log(`64 MiB attachment longest blocking stretch: ${longest.toFixed(2)} ms CPU`);
expect(longest).toBeLessThan(50);
expect(readFileSync(result.path).equals(bytes)).toBe(true);
rmSync(result.path, { force: true });
@@ -0,0 +1,80 @@
import { expect, test } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import pino from "pino";
import { CodexAppServerAgentClient } from "../agent/providers/codex-app-server-agent.js";
import { DaemonClient } from "../test-utils/daemon-client.js";
import { createMessageCollector } from "../test-utils/message-collector.js";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
import { canRunRealProvider } from "./real-provider-test-config.js";
test("keeps two concurrent startup steer messages in one real Codex turn", async (context) => {
if (!(await canRunRealProvider("codex"))) {
context.skip();
return;
}
const cwd = mkdtempSync(path.join(tmpdir(), "codex-steer-starting-"));
const logger = pino({ level: "silent" });
const daemon = await createTestPaseoDaemon({
agentClients: { codex: new CodexAppServerAgentClient(logger) },
logger,
});
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
const collector = createMessageCollector(client);
const subscriptions: ReturnType<DaemonClient["subscribeAgentTimeline"]>[] = [];
try {
await client.connect();
await client.fetchAgents({ subscribe: {} });
for (let attempt = 0; attempt < 3; attempt += 1) {
const agent = await client.createAgent({
cwd,
provider: "codex",
modeId: "full-access",
});
const subscription = client.subscribeAgentTimeline(agent.id, () => {});
subscriptions.push(subscription);
await subscription.ready;
await Promise.all([
client.sendAgentMessage(agent.id, "Reply exactly: FIRST", {
messageId: `first-${attempt}`,
activeTurnBehavior: "steer",
}),
client.sendAgentMessage(agent.id, "Reply exactly: SECOND", {
messageId: `second-${attempt}`,
activeTurnBehavior: "steer",
}),
]);
await client.waitForFinish(agent.id, 90_000);
const timeline = await client.fetchAgentTimeline(agent.id, { limit: 100 });
const users = timeline.entries.filter((entry) => entry.item.type === "user_message");
const events = collector.messages.flatMap((message) =>
message.type === "agent_stream" && message.payload.agentId === agent.id
? [message.payload.event]
: [],
);
expect(users.map((entry) => entry.item.text).sort()).toEqual([
"Reply exactly: FIRST",
"Reply exactly: SECOND",
]);
expect(new Set(users.map((entry) => entry.turnId)).size).toBe(1);
expect(events.filter((event) => event.type === "turn_started")).toHaveLength(1);
expect(
events.filter((event) => event.type === "turn_canceled" || event.type === "turn_failed"),
).toHaveLength(0);
expect(
timeline.entries.some(
(entry) =>
entry.item.type === "assistant_message" && entry.item.text.includes("[System Error]"),
),
).toBe(false);
}
} finally {
await Promise.all(subscriptions.map((subscription) => subscription.release()));
collector.unsubscribe();
await client.close();
await daemon.close();
rmSync(cwd, { recursive: true, force: true });
}
}, 300_000);
@@ -248,7 +248,7 @@ test("ordinary Hub requests survive daemon restart and restore an archived works
}),
).toMatchObject({ payload: { agentId, accepted: true } });
expect(hub.providerPromptTexts().filter((text) => text === "follow up")).toHaveLength(1);
}, 20_000);
});
test("Hub completes the standard hello before rejecting a second hello", async () => {
const hub = await launchRelationship();
@@ -1,10 +1,10 @@
import { mkdtemp, rm } from "node:fs/promises";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { expect, test } from "vitest";
import { DaemonClient } from "../test-utils/daemon-client.js";
import type { AgentSessionConfig } from "../agent/agent-sdk-types.js";
import type { AgentClient, AgentSessionConfig } from "../agent/agent-sdk-types.js";
import { createTestAgentClient } from "../test-utils/fake-agent-client.js";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
@@ -72,3 +72,71 @@ test("the configuration example saves Codex options and injects launch-only MCP
await rm(directory, { recursive: true, force: true });
}
}, 60_000);
test("session-open entrypoint contributions are optional and rederived for existing agents", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-entrypoint-hook-"));
const launches: { provider: string; env?: Record<string, string> }[] = [];
function recordingProvider(provider: "claude" | "codex"): AgentClient {
const inner = createTestAgentClient(provider);
return {
provider: inner.provider,
capabilities: inner.capabilities,
isAvailable: () => inner.isAvailable(),
fetchCatalog: (options) => inner.fetchCatalog(options),
createSession: async (...args: Parameters<typeof inner.createSession>) => {
launches.push({ provider, env: args[1]?.env });
return inner.createSession(...args);
},
resumeSession: async (...args: Parameters<typeof inner.resumeSession>) => {
launches.push({ provider, env: args[2]?.env });
return inner.resumeSession(...args);
},
};
}
const daemon = await createTestPaseoDaemon({
daemonVersion: "0.14.0-beta.2",
agentClients: { claude: recordingProvider("claude"), codex: recordingProvider("codex") },
mcpEnabled: false,
});
const client = new DaemonClient({
url: `ws://127.0.0.1:${daemon.port}/ws`,
appVersion: "0.14.0-beta.2",
});
try {
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id: "entrypoint-test", requirements: { paseo: ">=0.14.0-beta.2" } }),
);
await writeFile(
path.join(directory, "index.server.ts"),
`
export default function contribute(server) {
return server.before("agent.session_open", ({ request }) => {
if (request.provider !== "claude") return;
return { ...request, env: { ...request.env, CLAUDE_CODE_ENTRYPOINT: "custom-client" } };
});
}
`,
);
await client.connect();
await client.fetchAgents({ subscribe: {} });
const existing = await client.createAgent({ provider: "claude", cwd: directory });
expect(launches.at(-1)?.env?.CLAUDE_CODE_ENTRYPOINT).toBeUndefined();
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(directory);
await daemon.daemon.agentManager.reloadAgentSession(existing.id);
expect(launches.at(-1)?.env?.CLAUDE_CODE_ENTRYPOINT).toBe("custom-client");
await client.createAgent({ provider: "claude", cwd: directory });
expect(launches.at(-1)?.env?.CLAUDE_CODE_ENTRYPOINT).toBe("custom-client");
await client.createAgent({ provider: "codex", cwd: directory });
expect(launches.at(-1)).toMatchObject({ provider: "codex" });
expect(launches.at(-1)?.env?.CLAUDE_CODE_ENTRYPOINT).toBeUndefined();
await client.disablePlugin("entrypoint-test");
await daemon.daemon.agentManager.reloadAgentSession(existing.id);
expect(launches.at(-1)?.env?.CLAUDE_CODE_ENTRYPOINT).toBeUndefined();
} finally {
await client.close();
await daemon.close();
await rm(directory, { recursive: true, force: true });
}
}, 60_000);
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/website",
"version": "0.14.0-beta.2",
"version": "0.14.0",
"private": true,
"type": "module",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/builtin-plugins",
"version": "0.14.0-beta.2",
"version": "0.14.0",
"private": true,
"type": "module",
"scripts": {