mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-30 05:02:58 +00:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0edf7eea7c | ||
|
|
bf9dea429b | ||
|
|
629ca2f9b6 | ||
|
|
b27a29c32f | ||
|
|
32ddc6215a | ||
|
|
808024c95f | ||
|
|
2a630cf98c |
@@ -3,7 +3,7 @@ name: CI
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
# Every base branch, so stacked PRs and PRs into any base get the same checks.
|
# Every base branch, so stacked PRs and PRs into `next` get the same checks.
|
||||||
pull_request:
|
pull_request:
|
||||||
merge_group:
|
merge_group:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|||||||
+4
-4
@@ -1,6 +1,6 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
Not released yet. These changes are on `main` but not in 0.14.0; the next release entry includes them.
|
Not released yet. These changes are on `main` but not in 0.14.0-beta.2; the next release entry includes them.
|
||||||
|
|
||||||
Added:
|
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))
|
- Added `server.addHook` with `subject.operation.position` names and the awaited `worktree.create.after` hook before setup and agents; deprecated the previous plugin hook registration methods without changing their behavior ([#8081](https://github.com/getpaseo/paseo/pull/8081))
|
||||||
|
|
||||||
## 0.14.0 - 2026-10-29
|
## 0.14.0-beta.2 - 2026-10-28
|
||||||
|
|
||||||
Plugins can now add tools to individual agents and configure their MCP servers when sessions open.
|
Plugins can now add tools to individual agents and configure their MCP servers when sessions open.
|
||||||
|
|
||||||
@@ -43,6 +43,7 @@ Voice settings now let you configure speech providers and download local models
|
|||||||
- Reduced daemon stalls when opening and paging large Claude and OMP histories ([#7789](https://github.com/getpaseo/paseo/pull/7789), [#7940](https://github.com/getpaseo/paseo/pull/7940), [#7948](https://github.com/getpaseo/paseo/pull/7948), [#7958](https://github.com/getpaseo/paseo/pull/7958) by [@ragokan](https://github.com/ragokan))
|
- Reduced daemon stalls 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 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))
|
- 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
|
### Fixed
|
||||||
|
|
||||||
@@ -71,8 +72,7 @@ Voice settings now let you configure speech providers and download local models
|
|||||||
|
|
||||||
- Voice mode now defaults to off; enable it in Voice settings
|
- 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
|
- If environment variables select OpenAI speech, move keys and endpoints stored only in legacy `providers.openai` into Voice settings
|
||||||
- Before downgrading to 0.13.2, restore a backed-up `config.json` or convert speech credentials and idle-cleanup settings saved by 0.14.0
|
- Before downgrading to 0.13.2, restore a backed-up `config.json` or convert speech credentials and idle-cleanup settings saved by the beta
|
||||||
- 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
|
## 0.13.2 - 2026-10-26
|
||||||
|
|
||||||
|
|||||||
+4
-4
@@ -1,6 +1,6 @@
|
|||||||
# 更新日志
|
# 更新日志
|
||||||
|
|
||||||
尚未发布。以下变更已合入 `main`,但不包含在 0.14.0 中;下一个版本的条目会包含它们。
|
尚未发布。以下变更已合入 `main`,但不包含在 0.14.0-beta.2 中;下一个版本的条目会包含它们。
|
||||||
|
|
||||||
新增:
|
新增:
|
||||||
|
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
|
|
||||||
- 新增采用 `subject.operation.position` 命名的 `server.addHook` 和在 setup 及 Agent 之前等待的 `worktree.create.after` 钩子,弃用原有的插件钩子注册方法并保留原有行为 ([#8081](https://github.com/getpaseo/paseo/pull/8081))
|
- 新增采用 `subject.operation.position` 命名的 `server.addHook` 和在 setup 及 Agent 之前等待的 `worktree.create.after` 钩子,弃用原有的插件钩子注册方法并保留原有行为 ([#8081](https://github.com/getpaseo/paseo/pull/8081))
|
||||||
|
|
||||||
## 0.14.0 - 2026-10-29
|
## 0.14.0-beta.2 - 2026-10-28
|
||||||
|
|
||||||
插件现在可以为单个 Agent 添加工具,并在会话打开时配置其 MCP 服务器。
|
插件现在可以为单个 Agent 添加工具,并在会话打开时配置其 MCP 服务器。
|
||||||
|
|
||||||
@@ -43,6 +43,7 @@
|
|||||||
- 减少打开和分页加载大型 Claude 和 OMP 历史时的 daemon 卡顿 ([#7789](https://github.com/getpaseo/paseo/pull/7789), [#7940](https://github.com/getpaseo/paseo/pull/7940), [#7948](https://github.com/getpaseo/paseo/pull/7948), [#7958](https://github.com/getpaseo/paseo/pull/7958) by [@ragokan](https://github.com/ragokan))
|
- 减少打开和分页加载大型 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))
|
- 减少插件构建、图片处理和重新加载配置时的 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))
|
- 减少刷新工作区和项目时由文件系统操作造成的阻塞 ([#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))
|
||||||
|
|
||||||
### 修复
|
### 修复
|
||||||
|
|
||||||
@@ -71,8 +72,7 @@
|
|||||||
|
|
||||||
- 语音模式默认关闭,可在语音设置中启用
|
- 语音模式默认关闭,可在语音设置中启用
|
||||||
- 如果通过环境变量选择 OpenAI 语音,请将仅保存在旧版 `providers.openai` 中的密钥和服务地址移至语音设置
|
- 如果通过环境变量选择 OpenAI 语音,请将仅保存在旧版 `providers.openai` 中的密钥和服务地址移至语音设置
|
||||||
- 降级至 0.13.2 前,请恢复备份的 `config.json` 或转换 0.14.0 保存的语音凭据和空闲清理设置
|
- 降级至 0.13.2 前,请恢复备份的 `config.json` 或转换 beta 保存的语音凭据和空闲清理设置
|
||||||
- 安装可选的 [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
|
## 0.13.2 - 2026-10-26
|
||||||
|
|
||||||
|
|||||||
@@ -117,9 +117,10 @@ See [docs/development.md](docs/development.md) for full setup, build sync requir
|
|||||||
|
|
||||||
## Release branches
|
## Release branches
|
||||||
|
|
||||||
Merge normal PRs into `main`, including while a beta soaks or a release is being finalized. Each
|
When the user says "this goes to next", create or
|
||||||
release builds in a short-lived release workspace from `origin/main` or a release tag; see
|
retarget the PR to `next` and preserve that destination through delivery. Follow
|
||||||
[release base and source](docs/release.md#release-base-and-source).
|
[release branch discipline](docs/release.md#release-branch-discipline) for creating
|
||||||
|
and updating `next`, integrating it after a release, and releasing a hotfix from a tag.
|
||||||
|
|
||||||
## Critical rules
|
## Critical rules
|
||||||
|
|
||||||
|
|||||||
@@ -95,8 +95,4 @@ 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.
|
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).
|
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).
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
// elsewhere point SF_FONT_DIR at a folder holding SFNS.ttf copied from a Mac.
|
// elsewhere point SF_FONT_DIR at a folder holding SFNS.ttf copied from a Mac.
|
||||||
|
|
||||||
import { existsSync } from "node:fs";
|
import { existsSync } from "node:fs";
|
||||||
import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
|
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||||
@@ -23,7 +23,6 @@ const root = path.dirname(fileURLToPath(import.meta.url));
|
|||||||
const repo = path.resolve(root, "../..");
|
const repo = path.resolve(root, "../..");
|
||||||
const raw = (device, name) => path.join(root, "raw", device, `${name}.png`);
|
const raw = (device, name) => path.join(root, "raw", device, `${name}.png`);
|
||||||
const output = path.join(root, "output");
|
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
|
// 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.
|
// 2x. The frame's screen radius and Dynamic Island are in capture pixels.
|
||||||
@@ -117,7 +116,6 @@ const STORES = {
|
|||||||
},
|
},
|
||||||
google: {
|
google: {
|
||||||
device: "iphone",
|
device: "iphone",
|
||||||
androidScreenshots: "phoneScreenshots",
|
|
||||||
width: 1440,
|
width: 1440,
|
||||||
height: 2560,
|
height: 2560,
|
||||||
textTop: 150,
|
textTop: 150,
|
||||||
@@ -136,7 +134,6 @@ const STORES = {
|
|||||||
},
|
},
|
||||||
"google-tablet": {
|
"google-tablet": {
|
||||||
device: "ipad",
|
device: "ipad",
|
||||||
androidScreenshots: "tenInchScreenshots",
|
|
||||||
width: 1800,
|
width: 1800,
|
||||||
height: 3200,
|
height: 3200,
|
||||||
textTop: 200,
|
textTop: 200,
|
||||||
@@ -355,18 +352,6 @@ async function expectLines(view, selector, expected, label) {
|
|||||||
if (lines !== expected) throw new Error(`${label} wraps to ${lines} lines, expected ${expected}`);
|
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) {
|
async function renderPreview(rows) {
|
||||||
const tile = 440;
|
const tile = 440;
|
||||||
const gap = 40;
|
const gap = 40;
|
||||||
@@ -402,9 +387,7 @@ try {
|
|||||||
const marks = await loadAgentMarks(work);
|
const marks = await loadAgentMarks(work);
|
||||||
const rows = [];
|
const rows = [];
|
||||||
for (const [name, store] of Object.entries(STORES)) {
|
for (const [name, store] of Object.entries(STORES)) {
|
||||||
const files = await renderStore(browser, name, store, font, marks, work);
|
rows.push(await renderStore(browser, name, store, font, marks, work));
|
||||||
if (store.androidScreenshots) await linkAndroidScreenshots(store.androidScreenshots, files);
|
|
||||||
rows.push(files);
|
|
||||||
}
|
}
|
||||||
await renderPreview(rows);
|
await renderPreview(rows);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
+1
-4
@@ -215,8 +215,7 @@ fastlane/metadata/android/
|
|||||||
│ ├── images/
|
│ ├── images/
|
||||||
│ │ ├── icon.png (512x512)
|
│ │ ├── icon.png (512x512)
|
||||||
│ │ ├── featureGraphic.png (1024x500)
|
│ │ ├── featureGraphic.png (1024x500)
|
||||||
│ │ ├── phoneScreenshots/ (1.png, 2.png, ... linked to the store renders)
|
│ │ └── phoneScreenshots/ (1.png, 2.png, ...)
|
||||||
│ │ └── tenInchScreenshots/ (1.png, 2.png, ... linked to the store renders)
|
|
||||||
│ └── changelogs/ (generated — see below)
|
│ └── changelogs/ (generated — see below)
|
||||||
├── ja/
|
├── ja/
|
||||||
└── zh-CN/
|
└── zh-CN/
|
||||||
@@ -224,8 +223,6 @@ fastlane/metadata/android/
|
|||||||
|
|
||||||
Locale directories generally match `packages/app/src/i18n/locales.ts`, but note that `en` becomes `en-US`.
|
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.
|
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.
|
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.
|
||||||
|
|||||||
@@ -493,6 +493,16 @@ The supervisor alone publishes its ready worker's endpoint in `paseo.pid`, clear
|
|||||||
|
|
||||||
Worker restart retains supervisor arguments/environment and rereads the configuration file. Updating a package and observing its new worker version do not refresh the running supervisor code; the launcher owns full-process replacement. See [CLI lifecycle contracts](../public-docs/cli.md#daemon-management).
|
Worker restart retains supervisor arguments/environment and rereads the configuration file. Updating a package and observing its new worker version do not refresh the running supervisor code; the launcher owns full-process replacement. See [CLI lifecycle contracts](../public-docs/cli.md#daemon-management).
|
||||||
|
|
||||||
|
## Outbound networking
|
||||||
|
|
||||||
|
The daemon and its plugin processes honor `HTTPS_PROXY`, `HTTP_PROXY`, and `NO_PROXY`
|
||||||
|
(including lowercase forms, which take precedence). Fetch and relay, Hub, and connected-host WebSockets
|
||||||
|
share this policy. Loopback (`localhost`, `127.0.0.1`, `::1`) always goes direct.
|
||||||
|
A proxy without a scheme uses HTTP. Invalid values and unsupported proxy schemes
|
||||||
|
are treated as unset, with one warning naming the variable in `daemon.log`.
|
||||||
|
Agent and terminal environments are unchanged. System proxy settings and SOCKS-only
|
||||||
|
`ALL_PROXY` configurations are not supported.
|
||||||
|
|
||||||
## Deployment models
|
## Deployment models
|
||||||
|
|
||||||
1. **Local daemon** (default): `paseo daemon start` on `127.0.0.1:6767`
|
1. **Local daemon** (default): `paseo daemon start` on `127.0.0.1:6767`
|
||||||
|
|||||||
+7
-9
@@ -200,15 +200,13 @@ its own.
|
|||||||
|
|
||||||
## Gotchas
|
## Gotchas
|
||||||
|
|
||||||
- **Web enters without Reanimated.** For a custom keyframe, Reanimated's web path snapshots the
|
- **Released height.** Reanimated's web entering animation leaves an inline height snapshot on
|
||||||
surface's rect when the animation ends and writes it back as inline top/left/width/height 750ms
|
the surface. `AnchoredSurface` clears it, and a `revision` prop re-clears it when content
|
||||||
after the start. Whatever moved or resized the surface in between was undone: content that
|
identity changes — a pushed page taller than the one it replaced is clipped without that.
|
||||||
grew once its data arrived left the menu clipped, or hanging over its anchor, until it moved
|
- **Animate only once placed.** The same snapshot carries top/left, and Reanimated writes it back
|
||||||
again. `AnchoredSurface` plays the same keyframes through the Web Animations API on web, which
|
750ms after mount. `AnchoredSurface` remounts the surface when its position resolves so the
|
||||||
leaves no inline style; native keeps the Reanimated `Keyframe`.
|
entering animation never ends at the off-screen measuring position; on a slow machine that
|
||||||
- **Animate only once placed.** `AnchoredSurface` remounts the surface when its position resolves,
|
write moved the open menu back off-screen.
|
||||||
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
|
- **Sheets size to content.** `enableDynamicSizing`, not fixed snap points. A pushed page is
|
||||||
rarely the height of the page before it. A fill page is the one exception.
|
rarely the height of the page before it. A fill page is the one exception.
|
||||||
- **The sheet's content is teleported out of the menu's subtree**, so `MenuSheetSurface` rebuilds
|
- **The sheet's content is teleported out of the menu's subtree**, so `MenuSheetSurface` rebuilds
|
||||||
|
|||||||
+74
-147
@@ -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.
|
A release has exactly two steps. The agent does the first, the user authorizes the second.
|
||||||
|
|
||||||
**Preparation** (reversible — agent does this):
|
**Preparation** (local, reversible — agent does this):
|
||||||
|
|
||||||
- format, lint, typecheck all green
|
- format, lint, typecheck all green
|
||||||
- resolve the [release base](#release-base-and-source), get the [release inventory](#release-inventory) approved when one applies, and confirm CI for the resulting release source is green
|
- resolve the release source to one commit and confirm that commit's existing CI is green
|
||||||
- classify the diff from the previous stable to the release source as patch or minor, then show the
|
- 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
|
target version and rationale to the user
|
||||||
- draft the changelog, show it to the user, wait for review
|
- draft the changelog, show it to the user, wait for review
|
||||||
@@ -17,10 +17,9 @@ A release has exactly two steps. The agent does the first, the user authorizes t
|
|||||||
|
|
||||||
**Go-ahead** (user says "go ahead"):
|
**Go-ahead** (user says "go ahead"):
|
||||||
|
|
||||||
- commit the approved release inputs locally on the release branch
|
- commit the approved release inputs locally
|
||||||
- run the release, which publishes npm and pushes the release branch and tag
|
- run the release, which publishes npm and pushes the prepared branch and tag
|
||||||
- create the release heartbeat immediately and babysit it to completion
|
- create the release heartbeat immediately and babysit it to completion
|
||||||
- [reconcile `main`](#reconcile-main) with what shipped
|
|
||||||
|
|
||||||
Rules that apply to both steps:
|
Rules that apply to both steps:
|
||||||
|
|
||||||
@@ -28,128 +27,56 @@ Rules that apply to both steps:
|
|||||||
- No code changes bundled into the changelog commit or the release commit. Code shims live in their own commit, reviewed on their own merits.
|
- 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.
|
- 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.
|
- 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.
|
- If the user asks for a release preview, show the prospective changelog/release contents and answer questions, but do not commit, tag, publish, or run release commands until they explicitly authorize the release.
|
||||||
|
|
||||||
## Main stays open
|
## Release source and CI
|
||||||
|
|
||||||
Merge normal PRs into `main` at any time, including while a beta soaks or a release is being
|
The default release source is `origin/main`. Fetch `origin`, then record the
|
||||||
finalized. Each release builds in a short-lived release workspace, and its published tag records
|
resolved commit. The default release checkout is a clean local `main` whose
|
||||||
the shipped source.
|
`HEAD` equals `origin/main`.
|
||||||
|
|
||||||
## Choosing a path
|
An explicit user instruction can select another ref, such as a hotfix commit or
|
||||||
|
tag. Resolve that ref once and apply every source, diff, and CI check to that
|
||||||
|
commit instead of `origin/main`.
|
||||||
|
|
||||||
The user decides when the changes on `main` warrant a release and how much risk it takes:
|
Before making release-preparation commits, confirm the existing CI run for the
|
||||||
|
resolved commit is green. Pending CI is watched to completion. Release
|
||||||
|
preparation then stays local through the changelog, any explicitly requested ACP
|
||||||
|
catalog update, lockfile preparation, and the version commit. After approval,
|
||||||
|
commit the prepared inputs locally and run the release command. Its branch and
|
||||||
|
tag push is the one remote release batch and starts CI for the complete release
|
||||||
|
commit.
|
||||||
|
|
||||||
1. **Direct stable release** for a low-risk batch of polish and bug fixes. Stable desktop updates
|
## Release branch discipline
|
||||||
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`.
|
|
||||||
|
|
||||||
A beta follows these defaults, which the user can explicitly override:
|
While you finalize a release on `main`, use a temporary `next` branch for work intended for the following
|
||||||
|
release. This applies to both beta and stable releases.
|
||||||
|
|
||||||
- Soak a beta at least 24 hours before promotion. Soak longer for risky or breaking changes, or
|
- Create each new `next` from freshly fetched `origin/main`. Reuse it while active.
|
||||||
while feedback is open. Changes added in a later beta get their own soak; earlier soak time does
|
- "This goes to next" means create the PR against `next` or retarget an existing
|
||||||
not count for them.
|
PR, and keep that destination through delivery.
|
||||||
- A follow-up beta carries regressions, fixes, and polish for what that beta delivers. Unrelated
|
- Keep `next` current by merging `origin/main` into it as release fixes land.
|
||||||
work on `main` and pre-existing bugs wait for a later release unless the user explicitly selects
|
Avoid rebasing this shared branch because agents and open PRs depend on its history.
|
||||||
them.
|
- After the release ships, bring `next` up to date and open a `next` → `main` PR.
|
||||||
- Promotion ships the tested beta's product content unchanged and does not pull from `main`.
|
Pass CI and merge without squashing away the individual PR commits needed for
|
||||||
Adding a product change needs the user's explicit selection and a new beta, which soaks again.
|
the changelog. Retarget remaining PRs based on `next` to `main` and delete the integrated
|
||||||
- During a soak, ship urgent stable regressions and compatibility or provider model updates as a
|
`next`. Create it fresh when needed again.
|
||||||
[stable hotfix](#release-base-and-source) from the latest stable tag. The beta keeps its own
|
|
||||||
pace.
|
|
||||||
|
|
||||||
npm dist-tags are independent pointers:
|
**Setup still needed:** PR checks run for every base branch, but CI, Docker, and Nix
|
||||||
|
push runs target only `main`, and GitHub permits only squash merges. Enable
|
||||||
|
required-check protection for `next`, CI on its pushes, and merge commits for the
|
||||||
|
integration PR. Handle PR
|
||||||
|
base changes (`edited` events) so retargeting runs checks against the new base;
|
||||||
|
GitHub's default PR events do not cover this. Deployment triggers stay unchanged.
|
||||||
|
|
||||||
- A beta release publishes with `--tag beta` and moves only `beta`; `latest` remains on the
|
### Hotfix from a release tag
|
||||||
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.
|
|
||||||
|
|
||||||
## Release base and source
|
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
|
||||||
The release base is the tag or commit a release workspace starts from. The release source is the
|
use the normal release flow with it as the explicit source, choosing a new patch
|
||||||
base plus any approved cherry-picks, and every source, diff, and CI check uses it.
|
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
|
||||||
| Release | Base | Changes on top |
|
short-lived hotfix branch, not another maintained release track.
|
||||||
| -------------------------------------------------------- | ------------------------------------------------------------ | ---------------------------- |
|
|
||||||
| 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
|
## ACP catalog updates
|
||||||
|
|
||||||
@@ -164,6 +91,21 @@ ACP catalog work enters a release through an explicit user request:
|
|||||||
The release authorization covers the requested ACP commit. It ships in the same
|
The release authorization covers the requested ACP commit. It ships in the same
|
||||||
release push as the changelog and version commit.
|
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
|
## Release version decision
|
||||||
|
|
||||||
Every fresh release starts by classifying the full diff from the previous
|
Every fresh release starts by classifying the full diff from the previous
|
||||||
@@ -191,7 +133,7 @@ as described in **Fixing a failed release build**.
|
|||||||
|
|
||||||
Before running any stable release command:
|
Before running any stable release command:
|
||||||
|
|
||||||
- Make sure the release source passed CI, the approved release inputs are committed locally on the release branch, and the working tree is clean.
|
- Make sure the resolved release source passed CI, the approved release inputs are committed locally on the intended branch, and the working tree is clean.
|
||||||
- **Run `npm run format`, `npm run lint`, and `npm run typecheck` and commit any resulting changes BEFORE you start any `release:*` command.** `release:check` runs `npm install --workspaces --include-workspace-root` as part of `release:prepare`, which can mutate `package-lock.json` (e.g. churning `"dev": true` markers on optional deps). The next step, `version:all:*`, runs `npm version` which aborts when the working tree is dirty. If this happens mid-flight you have to commit the lockfile churn before retrying — and the pre-commit format hook will reject a lockfile-only commit because oxfmt internally skips `package-lock.json` while lefthook's glob still matches it. Avoid the whole mess by running format/lint/typecheck first, then `release:prepare` once on its own to absorb any lockfile churn into a normal commit, then start the release.
|
- **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.
|
- Do not use a release command as a substitute for checking whether the current commit is actually ready.
|
||||||
|
|
||||||
@@ -203,9 +145,9 @@ npm run release:minor
|
|||||||
|
|
||||||
This bumps the version across all workspaces, runs checks, publishes to npm, and pushes the branch + tag. The tag push triggers `Desktop Release`, `Android APK Release`, `Docker`, and `Release Notes Sync` on GitHub Actions. The workflows create the GitHub Release as a draft while builds and release-note sync run. EAS picks up the same tag via the EAS GitHub app and starts the iOS + Android store builds in parallel (see "Mobile builds (EAS)" below) — there is no mobile-release workflow under `.github/workflows`.
|
This bumps the version across all workspaces, runs checks, publishes to npm, and pushes the branch + tag. The tag push triggers `Desktop Release`, `Android APK Release`, `Docker`, and `Release Notes Sync` on GitHub Actions. The workflows create the GitHub Release as a draft while builds and release-note sync run. EAS picks up the same tag via the EAS GitHub app and starts the iOS + Android store builds in parallel (see "Mobile builds (EAS)" below) — there is no mobile-release workflow under `.github/workflows`.
|
||||||
|
|
||||||
After the stable release succeeds, check `npm view @getpaseo/cli dist-tags`. When the new stable
|
After the stable release succeeds, move npm's `beta` pointer to the new stable
|
||||||
is newer than `beta`, move `beta` to it for every published package. This changes dist-tags only;
|
version for every published package. This changes dist-tags only; do not
|
||||||
do not republish the packages:
|
republish the packages:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
PASEO_VERSION=$(node -p "require('./package.json').version")
|
PASEO_VERSION=$(node -p "require('./package.json').version")
|
||||||
@@ -214,8 +156,8 @@ for package in highlight relay protocol client plugin server cli; do
|
|||||||
done
|
done
|
||||||
```
|
```
|
||||||
|
|
||||||
Verify `latest` resolves to `PASEO_VERSION`, and `beta` resolves to `PASEO_VERSION` or the
|
Verify both npm tags now resolve to `PASEO_VERSION` before considering the
|
||||||
active newer beta, before considering the stable release complete.
|
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`.
|
The Docker workflow builds images from the checked-out source tree on pull requests and on `main` as non-publishing checks. Stable `vX.Y.Z` tag pushes publish `ghcr.io/getpaseo/paseo:X.Y.Z` and `ghcr.io/getpaseo/paseo:latest`; beta `vX.Y.Z-beta.N` tag pushes publish only `ghcr.io/getpaseo/paseo:X.Y.Z-beta.N` and never move `latest`.
|
||||||
|
|
||||||
@@ -233,7 +175,7 @@ npm run version:all:patch
|
|||||||
npm run version:all:minor
|
npm run version:all:minor
|
||||||
npm run release:publish # Publish to npm
|
npm run release:publish # Publish to npm
|
||||||
npm run release:push # Push HEAD + tag (triggers CI workflows)
|
npm run release:push # Push HEAD + tag (triggers CI workflows)
|
||||||
# Then move npm's beta dist-tag to this stable version when it is newer than beta (see above).
|
# Then move npm's beta dist-tag to this stable version using the command above.
|
||||||
```
|
```
|
||||||
|
|
||||||
## Beta flow
|
## Beta flow
|
||||||
@@ -363,8 +305,8 @@ macOS 13 maps to Darwin 22. The two values use different version domains; do not
|
|||||||
|
|
||||||
iOS and Android store builds are not in `.github/workflows`. They are triggered by the EAS GitHub app the moment the `v*` tag is pushed:
|
iOS and Android store builds are not in `.github/workflows`. They are triggered by the EAS GitHub app the moment the `v*` tag is pushed:
|
||||||
|
|
||||||
- **Android (Play Store)** — EAS builds with profile `production` and auto-submits to the Play Store via `eas submit` (EAS-managed credentials). A Fastlane lane then replaces the store screenshots.
|
- **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 uploads the store screenshots and submits that same build for App Store review.
|
- **iOS (TestFlight + App Store)** — EAS builds with profile `production`, uploads once through a `testflight` job, distributes the build to the `Paseo Beta` external group with Beta App Review submission, and a Fastlane lane submits that same build for App Store review.
|
||||||
- **Android APK (GitHub Release asset)** — separate, via `.github/workflows/android-apk-release.yml`. This is the only Android-related workflow that lives in this repo.
|
- **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.
|
EAS uses the local app version source. `packages/app/app.config.js` derives the native version from the package version. Android `versionCode` is `major * 1_000_000 + minor * 1_000 + patch`. iOS reserves 1,000 build slots per app version: beta `N` uses slot `N`, and stable uses slot `999`. For example, `0.2.6-beta.2` appears in App Store Connect as version `0.2.6` build `2006002`; stable uses build `2006999`. Rebuilding the same tag produces the same native build number; if a store has already accepted a binary and you need a different binary, cut the next beta or patch instead of relying on EAS remote auto-increment.
|
||||||
@@ -375,17 +317,6 @@ Stable iOS releases use one binary for two Apple review paths: Beta App Review f
|
|||||||
|
|
||||||
There is no mobile-release workflow under `.github/workflows`. The EAS GitHub app reads the workflows under `packages/app/.eas/workflows` and handles tag triggering directly.
|
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
|
### Watching mobile builds from the terminal
|
||||||
|
|
||||||
Use the EAS CLI from `packages/app/`:
|
Use the EAS CLI from `packages/app/`:
|
||||||
@@ -428,7 +359,6 @@ For the `Release Mobile` EAS workflow, these jobs must pass:
|
|||||||
- `submit_ios_for_review` — iOS build submitted for App Store review via Fastlane
|
- `submit_ios_for_review` — iOS build submitted for App Store review via Fastlane
|
||||||
- `build_android` — Android store binary built
|
- `build_android` — Android store binary built
|
||||||
- `submit_android` — Android binary submitted to the Play Store
|
- `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.
|
Do not treat `build_ios: SUCCESS` or `distribute_ios: SUCCESS` as a completed iOS release. `submit_ios_for_review: FAILURE` means the iOS release is blocked even if the build is visible in TestFlight.
|
||||||
|
|
||||||
@@ -547,7 +477,7 @@ git tag -f android-v0.1.28 HEAD && git push origin android-v0.1.28 --force
|
|||||||
git tag -f v0.1.29-beta.2 HEAD && git push origin v0.1.29-beta.2 --force
|
git tag -f v0.1.29-beta.2 HEAD && git push origin v0.1.29-beta.2 --force
|
||||||
```
|
```
|
||||||
|
|
||||||
This ensures the checkout ref matches the code you want built, with the fix included.
|
This ensures the checkout ref matches the actual code on `main` with the fix included.
|
||||||
|
|
||||||
- `vX.Y.Z` or `vX.Y.Z-beta.N` rebuilds the full tagged release
|
- `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
|
- `desktop-vX.Y.Z` rebuilds desktop for all desktop platforms only
|
||||||
@@ -690,7 +620,7 @@ Rules:
|
|||||||
|
|
||||||
This returns every distinct GitHub login that authored or co-authored a commit in the PR. Use those logins for attribution. Fall back to `gh pr view N --json author` only if the commits command returns nothing (which should not happen for merged PRs).
|
This returns every distinct GitHub login that authored or co-authored a commit in the PR. Use those logins for attribution. Fall back to `gh pr view N --json author` only if the commits command returns nothing (which should not happen for merged PRs).
|
||||||
|
|
||||||
When listing PR numbers, `git log --cherry-pick --right-only --no-merges --format='%H %s' v<previous>...<release-source-sha> | grep -E '\(#[0-9]+\)$'` pulls the PR number out of squash commit subjects and skips commits the previous stable already shipped as cherry-picks.
|
When listing PR numbers, `git log --format='%H %s' v<previous>..<release-source-sha> | grep -E '\(#[0-9]+\)$'` pulls the PR number out of squash commit subjects.
|
||||||
|
|
||||||
## Changelog ordering
|
## Changelog ordering
|
||||||
|
|
||||||
@@ -722,11 +652,11 @@ Every entry covers `previous stable release → release source`: first beta, lat
|
|||||||
|
|
||||||
### Beta release
|
### Beta release
|
||||||
|
|
||||||
- [ ] The release base and changes match the [release base table](#release-base-and-source) and any approved [inventory](#release-inventory), and CI for the release source is green
|
- [ ] The resolved release source is the intended commit (default `origin/main`) and its existing CI is green
|
||||||
- [ ] Every PR in the release range has been opened, and its full description and every linked issue have been read before drafting the changelog
|
- [ ] 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
|
- [ ] Fold this beta into the series' single `CHANGELOG.md` entry (heading `## X.Y.Z-beta.N - YYYY-MM-DD`, previous stable → release source), review it against the changelog policy, get approval, and commit it with its [`CHANGELOG.zh-CN.md` translation](#changelog-translations) before cutting the release
|
||||||
- [ ] The diff from the previous stable to the resolved release source is classified as patch or minor, with the target version and rationale approved
|
- [ ] The diff from the previous stable to the resolved release source is classified as patch or minor, with the target version and rationale approved
|
||||||
- [ ] The changelog, ACP, lockfile, and version commits stayed local until the approved release command pushed the release branch and tag
|
- [ ] Release preparation stayed local until the approved release command pushed the complete branch and tag
|
||||||
- [ ] `npm run release:beta:patch`, `npm run release:beta:minor`, or `npm run release:beta:next` completes successfully
|
- [ ] `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
|
- [ ] Every GitHub Actions run for the complete release commit and tag is green
|
||||||
- [ ] npm shows the version under the `beta` dist-tag, not `latest`
|
- [ ] npm shows the version under the `beta` dist-tag, not `latest`
|
||||||
@@ -737,24 +667,22 @@ Every entry covers `previous stable release → release source`: first beta, lat
|
|||||||
- [ ] GitHub `Docker` workflow is green and the versioned beta image is published without moving `latest`
|
- [ ] GitHub `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
|
- [ ] 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
|
- [ ] 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
|
- [ ] The release heartbeat was created after the tag push and deleted only after every item above passed
|
||||||
|
|
||||||
### Stable release (or promotion)
|
### Stable release (or promotion)
|
||||||
|
|
||||||
- [ ] Run the pre-release sanity check (see above) and address any findings
|
- [ ] 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 diff from the previous stable to the resolved release source is classified as patch or minor, with the target version and rationale approved
|
||||||
- [ ] The release base and changes match the [release base table](#release-base-and-source) and any approved [inventory](#release-inventory), and CI for the release source is green
|
- [ ] The resolved release source is the intended commit (default `origin/main`) and its existing CI 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
|
- [ ] 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 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
|
- [ ] 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
|
- [ ] 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
|
- [ ] Verify the changelog heading follows strict `## X.Y.Z - YYYY-MM-DD` format
|
||||||
- [ ] The changelog, ACP, lockfile, and version commits stayed local until the approved release command pushed the release branch and tag
|
- [ ] Release preparation stayed local until the approved release command pushed the complete branch and tag
|
||||||
- [ ] `npm run release:patch`, `npm run release:minor`, or `npm run release:promote` completes successfully
|
- [ ] `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
|
- [ ] Every GitHub Actions run for the complete release commit and tag is green
|
||||||
- [ ] `latest` resolves to the new stable, and `beta` resolves to it too unless an active newer beta holds `beta`
|
- [ ] Move npm's `beta` dist-tag to the new stable version for every published package and verify both `latest` and `beta` resolve to it
|
||||||
- [ ] The GitHub Release was published only after the three stable manifests were uploaded, and it has the changelog body and every expected macOS, Linux, Windows, and Android APK asset
|
- [ ] 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
|
- [ ] GitHub `Desktop Release` workflow for the `v*` tag is green
|
||||||
- [ ] The GitHub Release contains `latest-mac.yml`, `latest-linux.yml`, and `latest.yml`
|
- [ ] The GitHub Release contains `latest-mac.yml`, `latest-linux.yml`, and `latest.yml`
|
||||||
@@ -768,5 +696,4 @@ Every entry covers `previous stable release → release source`: first beta, lat
|
|||||||
- [ ] EAS iOS `submit_ios_for_review` succeeds, putting the build into App Store review
|
- [ ] EAS iOS `submit_ios_for_review` succeeds, putting the build into App Store review
|
||||||
- [ ] EAS Android `build_android` completes for the same tag
|
- [ ] EAS Android `build_android` completes for the same tag
|
||||||
- [ ] EAS Android `submit_android` succeeds, putting the build on its Play Store track
|
- [ ] 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
|
- [ ] The release heartbeat was created after the tag push and deleted only after every item above passed
|
||||||
|
|||||||
@@ -358,14 +358,6 @@ export to `src`, but browser and Electron Metro resolve its `browser` export to
|
|||||||
`lib/module`; CommonJS consumers use `lib/commonjs`. A source-only patch leaves
|
`lib/module`; CommonJS consumers use `lib/commonjs`. A source-only patch leaves
|
||||||
Electron running the old code.
|
Electron running the old code.
|
||||||
|
|
||||||
The adaptive-theme patch keeps `colorScheme` reads off the media-query lists
|
|
||||||
owned by Unistyles' change listeners. Chromium defers their events while a window
|
|
||||||
is hidden; reading `matches` on those same lists can consume the pending change.
|
|
||||||
CSS variables then follow the OS theme while `withUnistyles` props retain the old
|
|
||||||
colors. Keep this patch until upstream reads the scheme through separate lists.
|
|
||||||
The desktop browser E2E checks both transition directions in an isolated Electron
|
|
||||||
process, because Playwright's focus emulation prevents real hidden-page behavior.
|
|
||||||
|
|
||||||
Register every dependency patch in `scripts/postinstall-patches.mjs`. A file in
|
Register every dependency patch in `scripts/postinstall-patches.mjs`. A file in
|
||||||
`patches/` is inert unless that script knows which installed package activates
|
`patches/` is inert unless that script knows which installed package activates
|
||||||
it.
|
it.
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
Plugins can now add tools to individual agents and configure their MCP servers when sessions open.
|
|
||||||
|
|
||||||
Completed conversation turns can be collapsed into summaries and expanded to read the full exchange.
|
|
||||||
|
|
||||||
Voice settings now let you configure speech providers and download local models with one tap, including SenseVoice and Whisper for Chinese dictation. Voice mode is now opt-in.
|
|
||||||
|
|
||||||
Added
|
|
||||||
- Let plugins give tools to specific agents
|
|
||||||
|
|
||||||
Full notes: https://paseo.sh/changelog
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
Plugins can now add tools to individual agents and configure their MCP servers when sessions open.
|
|
||||||
|
|
||||||
Completed conversation turns can be collapsed into summaries and expanded to read the full exchange.
|
|
||||||
|
|
||||||
Voice settings now let you configure speech providers and download local models with one tap, including SenseVoice and Whisper for Chinese dictation. Voice mode is now opt-in.
|
|
||||||
|
|
||||||
Added
|
|
||||||
- Let plugins give tools to specific agents
|
|
||||||
|
|
||||||
Full notes: https://paseo.sh/changelog
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
Plugins can now add tools to individual agents and configure their MCP servers when sessions open.
|
|
||||||
|
|
||||||
Completed conversation turns can be collapsed into summaries and expanded to read the full exchange.
|
|
||||||
|
|
||||||
Voice settings now let you configure speech providers and download local models with one tap, including SenseVoice and Whisper for Chinese dictation. Voice mode is now opt-in.
|
|
||||||
|
|
||||||
Added
|
|
||||||
- Let plugins give tools to specific agents
|
|
||||||
|
|
||||||
Full notes: https://paseo.sh/changelog
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
Plugins can now add tools to individual agents and configure their MCP servers when sessions open.
|
|
||||||
|
|
||||||
Completed conversation turns can be collapsed into summaries and expanded to read the full exchange.
|
|
||||||
|
|
||||||
Voice settings now let you configure speech providers and download local models with one tap, including SenseVoice and Whisper for Chinese dictation. Voice mode is now opt-in.
|
|
||||||
|
|
||||||
Added
|
|
||||||
- Let plugins give tools to specific agents
|
|
||||||
|
|
||||||
Full notes: https://paseo.sh/changelog
|
|
||||||
@@ -1 +1 @@
|
|||||||
../../../../../../assets/store-screenshots/output/google/01-sidebar.png
|
../../../../../../packages/website/public/phone-1.png
|
||||||
@@ -1 +1 @@
|
|||||||
../../../../../../assets/store-screenshots/output/google/02-chat.png
|
../../../../../../packages/website/public/phone-2.png
|
||||||
@@ -1 +1 @@
|
|||||||
../../../../../../assets/store-screenshots/output/google/03-plugins.png
|
../../../../../../packages/website/public/phone-3.png
|
||||||
@@ -1 +0,0 @@
|
|||||||
../../../../../../assets/store-screenshots/output/google/04-review.png
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
../../../../../../assets/store-screenshots/output/google/05-terminal.png
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
../../../../../../assets/store-screenshots/output/google-tablet/01-sidebar.png
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
../../../../../../assets/store-screenshots/output/google-tablet/02-chat.png
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
../../../../../../assets/store-screenshots/output/google-tablet/03-plugins.png
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
../../../../../../assets/store-screenshots/output/google-tablet/04-review.png
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
../../../../../../assets/store-screenshots/output/google-tablet/05-terminal.png
|
|
||||||
+1
-1
@@ -1 +1 @@
|
|||||||
sha256-6C4Rjh0mRVrC9akBVGK0NZaCaLJhwKObT7HrgPmcvyw=
|
sha256-AmMp/4ugSUKXTL6H0oJaGJMztVGiYCPXeWteQaaaguw=
|
||||||
|
|||||||
Generated
+52
-28
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.14.0",
|
"version": "0.14.0-beta.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.14.0",
|
"version": "0.14.0-beta.2",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
@@ -37590,7 +37590,7 @@
|
|||||||
},
|
},
|
||||||
"packages/app": {
|
"packages/app": {
|
||||||
"name": "@getpaseo/app",
|
"name": "@getpaseo/app",
|
||||||
"version": "0.14.0",
|
"version": "0.14.0-beta.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/commands": "6.10.4",
|
"@codemirror/commands": "6.10.4",
|
||||||
"@codemirror/language": "6.12.4",
|
"@codemirror/language": "6.12.4",
|
||||||
@@ -39793,12 +39793,12 @@
|
|||||||
},
|
},
|
||||||
"packages/cli": {
|
"packages/cli": {
|
||||||
"name": "@getpaseo/cli",
|
"name": "@getpaseo/cli",
|
||||||
"version": "0.14.0",
|
"version": "0.14.0-beta.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clack/prompts": "^1.0.0",
|
"@clack/prompts": "^1.0.0",
|
||||||
"@getpaseo/client": "0.14.0",
|
"@getpaseo/client": "0.14.0-beta.2",
|
||||||
"@getpaseo/protocol": "0.14.0",
|
"@getpaseo/protocol": "0.14.0-beta.2",
|
||||||
"@getpaseo/server": "0.14.0",
|
"@getpaseo/server": "0.14.0-beta.2",
|
||||||
"chalk": "^5.3.0",
|
"chalk": "^5.3.0",
|
||||||
"commander": "^12.0.0",
|
"commander": "^12.0.0",
|
||||||
"mime-types": "^2.1.35",
|
"mime-types": "^2.1.35",
|
||||||
@@ -40045,10 +40045,10 @@
|
|||||||
},
|
},
|
||||||
"packages/client": {
|
"packages/client": {
|
||||||
"name": "@getpaseo/client",
|
"name": "@getpaseo/client",
|
||||||
"version": "0.14.0",
|
"version": "0.14.0-beta.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@getpaseo/protocol": "0.14.0",
|
"@getpaseo/protocol": "0.14.0-beta.2",
|
||||||
"@getpaseo/relay": "0.14.0",
|
"@getpaseo/relay": "0.14.0-beta.2",
|
||||||
"ws": "^8.20.0",
|
"ws": "^8.20.0",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
@@ -40060,7 +40060,7 @@
|
|||||||
},
|
},
|
||||||
"packages/desktop": {
|
"packages/desktop": {
|
||||||
"name": "@getpaseo/desktop",
|
"name": "@getpaseo/desktop",
|
||||||
"version": "0.14.0",
|
"version": "0.14.0-beta.2",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@getpaseo/cli": "*",
|
"@getpaseo/cli": "*",
|
||||||
@@ -40304,7 +40304,7 @@
|
|||||||
},
|
},
|
||||||
"packages/expo-two-way-audio": {
|
"packages/expo-two-way-audio": {
|
||||||
"name": "@getpaseo/expo-two-way-audio",
|
"name": "@getpaseo/expo-two-way-audio",
|
||||||
"version": "0.14.0",
|
"version": "0.14.0-beta.2",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/jest": "^29.5.14",
|
"@types/jest": "^29.5.14",
|
||||||
@@ -40350,7 +40350,7 @@
|
|||||||
},
|
},
|
||||||
"packages/highlight": {
|
"packages/highlight": {
|
||||||
"name": "@getpaseo/highlight",
|
"name": "@getpaseo/highlight",
|
||||||
"version": "0.14.0",
|
"version": "0.14.0-beta.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/language": "6.12.4",
|
"@codemirror/language": "6.12.4",
|
||||||
"@codemirror/legacy-modes": "^6.5.3",
|
"@codemirror/legacy-modes": "^6.5.3",
|
||||||
@@ -40379,15 +40379,15 @@
|
|||||||
},
|
},
|
||||||
"packages/plugin": {
|
"packages/plugin": {
|
||||||
"name": "@getpaseo/plugin",
|
"name": "@getpaseo/plugin",
|
||||||
"version": "0.14.0",
|
"version": "0.14.0-beta.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@agentclientprotocol/sdk": "^1.4.0",
|
"@agentclientprotocol/sdk": "^1.4.0",
|
||||||
"i18next": "^26.3.0",
|
"i18next": "^26.3.0",
|
||||||
"use-sync-external-store": "^1.6.0"
|
"use-sync-external-store": "^1.6.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@getpaseo/client": "0.14.0",
|
"@getpaseo/client": "0.14.0-beta.2",
|
||||||
"@getpaseo/protocol": "0.14.0",
|
"@getpaseo/protocol": "0.14.0-beta.2",
|
||||||
"@types/node": "^20.9.0",
|
"@types/node": "^20.9.0",
|
||||||
"@types/react": "~19.2.0",
|
"@types/react": "~19.2.0",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
@@ -40397,8 +40397,8 @@
|
|||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@getpaseo/client": "0.14.0",
|
"@getpaseo/client": "0.14.0-beta.2",
|
||||||
"@getpaseo/protocol": "0.14.0",
|
"@getpaseo/protocol": "0.14.0-beta.2",
|
||||||
"react": ">=19.1.0 <20",
|
"react": ">=19.1.0 <20",
|
||||||
"react-native": ">=0.81.5",
|
"react-native": ">=0.81.5",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
@@ -40420,7 +40420,7 @@
|
|||||||
},
|
},
|
||||||
"packages/protocol": {
|
"packages/protocol": {
|
||||||
"name": "@getpaseo/protocol",
|
"name": "@getpaseo/protocol",
|
||||||
"version": "0.14.0",
|
"version": "0.14.0-beta.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"semver": "^7.8.5",
|
"semver": "^7.8.5",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
@@ -40447,7 +40447,7 @@
|
|||||||
},
|
},
|
||||||
"packages/relay": {
|
"packages/relay": {
|
||||||
"name": "@getpaseo/relay",
|
"name": "@getpaseo/relay",
|
||||||
"version": "0.14.0",
|
"version": "0.14.0-beta.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@noble/ciphers": "^2.4.0",
|
"@noble/ciphers": "^2.4.0",
|
||||||
"@noble/curves": "^2.4.0",
|
"@noble/curves": "^2.4.0",
|
||||||
@@ -40668,16 +40668,16 @@
|
|||||||
},
|
},
|
||||||
"packages/server": {
|
"packages/server": {
|
||||||
"name": "@getpaseo/server",
|
"name": "@getpaseo/server",
|
||||||
"version": "0.14.0",
|
"version": "0.14.0-beta.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@agentclientprotocol/sdk": "^0.17.1",
|
"@agentclientprotocol/sdk": "^0.17.1",
|
||||||
"@anthropic-ai/claude-agent-sdk": "0.3.246",
|
"@anthropic-ai/claude-agent-sdk": "0.3.246",
|
||||||
"@anthropic-ai/sdk": "^0.104.2",
|
"@anthropic-ai/sdk": "^0.104.2",
|
||||||
"@getpaseo/client": "0.14.0",
|
"@getpaseo/client": "0.14.0-beta.2",
|
||||||
"@getpaseo/highlight": "0.14.0",
|
"@getpaseo/highlight": "0.14.0-beta.2",
|
||||||
"@getpaseo/plugin": "0.14.0",
|
"@getpaseo/plugin": "0.14.0-beta.2",
|
||||||
"@getpaseo/protocol": "0.14.0",
|
"@getpaseo/protocol": "0.14.0-beta.2",
|
||||||
"@getpaseo/relay": "0.14.0",
|
"@getpaseo/relay": "0.14.0-beta.2",
|
||||||
"@isaacs/ttlcache": "^2.1.4",
|
"@isaacs/ttlcache": "^2.1.4",
|
||||||
"@modelcontextprotocol/sdk": "1.29.0",
|
"@modelcontextprotocol/sdk": "1.29.0",
|
||||||
"@opencode-ai/sdk": "1.14.46",
|
"@opencode-ai/sdk": "1.14.46",
|
||||||
@@ -40690,6 +40690,7 @@
|
|||||||
"esbuild": "^0.25.12",
|
"esbuild": "^0.25.12",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"fast-deep-equal": "^3.1.3",
|
"fast-deep-equal": "^3.1.3",
|
||||||
|
"https-proxy-agent": "^7.0.6",
|
||||||
"lru-cache": "^11.5.1",
|
"lru-cache": "^11.5.1",
|
||||||
"markdown-it": "^10.0.0",
|
"markdown-it": "^10.0.0",
|
||||||
"mnemonic-id": "^3.2.7",
|
"mnemonic-id": "^3.2.7",
|
||||||
@@ -40707,6 +40708,7 @@
|
|||||||
"strip-ansi": "^7.1.2",
|
"strip-ansi": "^7.1.2",
|
||||||
"tree-kill": "^1.2.2",
|
"tree-kill": "^1.2.2",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
|
"undici": "^7.24.8",
|
||||||
"uuid": "^9.0.1",
|
"uuid": "^9.0.1",
|
||||||
"which": "^5.0.0",
|
"which": "^5.0.0",
|
||||||
"ws": "^8.14.2",
|
"ws": "^8.14.2",
|
||||||
@@ -41280,6 +41282,15 @@
|
|||||||
"url": "https://opencollective.com/vitest"
|
"url": "https://opencollective.com/vitest"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"packages/server/node_modules/agent-base": {
|
||||||
|
"version": "7.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||||
|
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14"
|
||||||
|
}
|
||||||
|
},
|
||||||
"packages/server/node_modules/ajv": {
|
"packages/server/node_modules/ajv": {
|
||||||
"version": "8.20.0",
|
"version": "8.20.0",
|
||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
|
||||||
@@ -41349,6 +41360,19 @@
|
|||||||
"@esbuild/win32-x64": "0.25.12"
|
"@esbuild/win32-x64": "0.25.12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"packages/server/node_modules/https-proxy-agent": {
|
||||||
|
"version": "7.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||||
|
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"agent-base": "^7.1.2",
|
||||||
|
"debug": "4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14"
|
||||||
|
}
|
||||||
|
},
|
||||||
"packages/server/node_modules/isexe": {
|
"packages/server/node_modules/isexe": {
|
||||||
"version": "3.1.5",
|
"version": "3.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz",
|
||||||
@@ -41598,7 +41622,7 @@
|
|||||||
},
|
},
|
||||||
"packages/website": {
|
"packages/website": {
|
||||||
"name": "@getpaseo/website",
|
"name": "@getpaseo/website",
|
||||||
"version": "0.14.0",
|
"version": "0.14.0-beta.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cloudflare/vite-plugin": "^1.29.1",
|
"@cloudflare/vite-plugin": "^1.29.1",
|
||||||
"@cloudflare/workers-types": "^4.20260317.1",
|
"@cloudflare/workers-types": "^4.20260317.1",
|
||||||
@@ -43208,7 +43232,7 @@
|
|||||||
},
|
},
|
||||||
"plugins": {
|
"plugins": {
|
||||||
"name": "@getpaseo/builtin-plugins",
|
"name": "@getpaseo/builtin-plugins",
|
||||||
"version": "0.14.0",
|
"version": "0.14.0-beta.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@getpaseo/plugin": "*",
|
"@getpaseo/plugin": "*",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.14.0",
|
"version": "0.14.0-beta.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
|
|||||||
@@ -42,20 +42,6 @@ jobs:
|
|||||||
build_id: ${{ needs.build_android.outputs.build_id }}
|
build_id: ${{ needs.build_android.outputs.build_id }}
|
||||||
profile: production
|
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:
|
submit_ios_for_review:
|
||||||
name: Submit iOS for App Store review
|
name: Submit iOS for App Store review
|
||||||
needs: [build_ios, distribute_ios]
|
needs: [build_ios, distribute_ios]
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
name: Upload Google Play screenshots
|
|
||||||
|
|
||||||
# Standalone re-trigger for the release workflow's Google Play screenshot step.
|
|
||||||
# Replaces the en-US phone and tablet screenshots with the committed renders,
|
|
||||||
# without rebuilding or resubmitting the app.
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
android_version_code:
|
|
||||||
type: string
|
|
||||||
required: false
|
|
||||||
description: "versionCode of the production release to attach the change to, e.g. 14000. Leave empty when the production track has a single release."
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
upload_android_screenshots:
|
|
||||||
name: Upload Google Play screenshots
|
|
||||||
environment: production
|
|
||||||
runs_on: macos-medium
|
|
||||||
steps:
|
|
||||||
- uses: eas/checkout
|
|
||||||
- name: Install fastlane
|
|
||||||
run: bundle install
|
|
||||||
- name: Upload screenshots
|
|
||||||
run: |
|
|
||||||
export ANDROID_VERSION_CODE="${{ inputs.android_version_code }}"
|
|
||||||
bundle exec fastlane android upload_screenshots
|
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
const withAndroidOrientation = require("./plugins/with-android-orientation");
|
|
||||||
const fs = require("node:fs");
|
const fs = require("node:fs");
|
||||||
const path = require("node:path");
|
const path = require("node:path");
|
||||||
const pkg = require("./package.json");
|
const pkg = require("./package.json");
|
||||||
@@ -143,7 +142,6 @@ export default {
|
|||||||
searchPaths: ["../../node_modules", "./node_modules"],
|
searchPaths: ["../../node_modules", "./node_modules"],
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
withAndroidOrientation,
|
|
||||||
"expo-router",
|
"expo-router",
|
||||||
withPasteInput,
|
withPasteInput,
|
||||||
withAndroidScroll,
|
withAndroidScroll,
|
||||||
|
|||||||
@@ -59,31 +59,3 @@ describe("Android app config", () => {
|
|||||||
30_000,
|
30_000,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps the Android manifest portrait for phone startup", async () => {
|
|
||||||
const manifest = await resolveAndroidManifest();
|
|
||||||
const activity = manifest.application[0].activity.find(
|
|
||||||
(entry: ManifestEntry) => entry.$["android:name"] === ".MainActivity",
|
|
||||||
);
|
|
||||||
expect(activity.$["android:screenOrientation"]).toBe("portrait");
|
|
||||||
}, 30_000);
|
|
||||||
|
|
||||||
it("updates the native orientation before launch and when screen size changes", async () => {
|
|
||||||
const plugin = createRequire(import.meta.url)("./plugins/with-android-orientation.js");
|
|
||||||
const config = plugin({ name: "test", slug: "test" });
|
|
||||||
const result = await config.mods.android.mainActivity({
|
|
||||||
modRequest: {},
|
|
||||||
modResults: {
|
|
||||||
language: "kt",
|
|
||||||
contents:
|
|
||||||
"class MainActivity {\n override fun onCreate(savedInstanceState: Bundle?) {\n super.onCreate(null)\n }\n}",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(result.modResults.contents).toContain("smallestScreenWidthDp >= 600");
|
|
||||||
expect(result.modResults.contents).toContain("SCREEN_ORIENTATION_UNSPECIFIED");
|
|
||||||
expect(result.modResults.contents).toContain("SCREEN_ORIENTATION_PORTRAIT");
|
|
||||||
expect(result.modResults.contents).toContain(
|
|
||||||
"updateOrientation(resources.configuration)\n super.onCreate(null)",
|
|
||||||
);
|
|
||||||
expect(result.modResults.contents).toContain("updateOrientation(newConfig)");
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,134 +0,0 @@
|
|||||||
import { expect, type Locator, type Page } from "@playwright/test";
|
|
||||||
import { test } from "../support/fixtures";
|
|
||||||
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
|
|
||||||
import { createCreationScenario } from "../support/helpers/creation";
|
|
||||||
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
|
|
||||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
|
||||||
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
|
|
||||||
|
|
||||||
const DRAG_DISTANCE = -40;
|
|
||||||
const TOLERANCE_PX = 2;
|
|
||||||
|
|
||||||
function splitDividers(page: Page): Locator {
|
|
||||||
return page.getByTestId("workspace-split-resize-handle").filter({ visible: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function dividerX(divider: Locator): Promise<number> {
|
|
||||||
const box = await divider.boundingBox();
|
|
||||||
if (!box) throw new Error("Divider has no bounding box");
|
|
||||||
return box.x;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function dragDivider(divider: Locator, deltaX: number): Promise<void> {
|
|
||||||
const page = divider.page();
|
|
||||||
const box = await divider.boundingBox();
|
|
||||||
if (!box) throw new Error("Divider has no bounding box");
|
|
||||||
const x = box.x + box.width / 2;
|
|
||||||
const y = box.y + box.height / 2;
|
|
||||||
await page.mouse.move(x, y);
|
|
||||||
await page.mouse.down();
|
|
||||||
await page.mouse.move(x + deltaX, y, { steps: 8 });
|
|
||||||
await page.mouse.up();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function dragDividerWithTouch(divider: Locator, deltaX: number): Promise<void> {
|
|
||||||
const page = divider.page();
|
|
||||||
const box = await divider.boundingBox();
|
|
||||||
if (!box) throw new Error("Divider has no bounding box");
|
|
||||||
const x = box.x + box.width / 2;
|
|
||||||
const y = box.y + box.height / 2;
|
|
||||||
const input = await page.context().newCDPSession(page);
|
|
||||||
try {
|
|
||||||
await input.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ x, y }] });
|
|
||||||
for (let step = 1; step <= 8; step++) {
|
|
||||||
await input.send("Input.dispatchTouchEvent", {
|
|
||||||
type: "touchMove",
|
|
||||||
touchPoints: [{ x: x + (deltaX * step) / 8, y }],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
await input.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] });
|
|
||||||
} finally {
|
|
||||||
await input.detach();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function splitIntoFourPanes(page: Page): Promise<void> {
|
|
||||||
for (const expectedDividers of [1, 2, 3]) {
|
|
||||||
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
|
|
||||||
await expect(splitDividers(page)).toHaveCount(expectedDividers);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function expectDividersTrackPointer(
|
|
||||||
page: Page,
|
|
||||||
distance = DRAG_DISTANCE,
|
|
||||||
drag = dragDivider,
|
|
||||||
): Promise<void> {
|
|
||||||
for (const index of [0, 1, 2]) {
|
|
||||||
const divider = splitDividers(page).nth(index);
|
|
||||||
const before = await dividerX(divider);
|
|
||||||
await drag(divider, distance);
|
|
||||||
await expect
|
|
||||||
.poll(async () => Math.abs((await dividerX(divider)) - before - distance), {
|
|
||||||
message: `divider ${index + 1} should stay under the pointer`,
|
|
||||||
})
|
|
||||||
.toBeLessThanOrEqual(TOLERANCE_PX);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const hasTouch of [false, true]) {
|
|
||||||
test.describe(`Split pane drag tracking (${hasTouch ? "coarse" : "fine"} pointer)`, () => {
|
|
||||||
test.use({ hasTouch });
|
|
||||||
|
|
||||||
test("a divider moves as far as the pointer in the main row", async ({ page }) => {
|
|
||||||
await page.setViewportSize({ width: 1600, height: 900 });
|
|
||||||
const workspace = await seedWorkspace({ repoPrefix: "split-drag-tracking-" });
|
|
||||||
try {
|
|
||||||
await gotoWorkspace(page, workspace.workspaceId);
|
|
||||||
await waitForWorkspaceTabsVisible(page);
|
|
||||||
await clickNewChat(page);
|
|
||||||
await splitIntoFourPanes(page);
|
|
||||||
await expectDividersTrackPointer(
|
|
||||||
page,
|
|
||||||
DRAG_DISTANCE,
|
|
||||||
hasTouch ? dragDividerWithTouch : dragDivider,
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
await workspace.cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("dividers track the pointer after app creation, Explorer changes and reload", async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
await page.setViewportSize({ width: 1600, height: 900 });
|
|
||||||
const creation = await createCreationScenario(page);
|
|
||||||
try {
|
|
||||||
await creation.openWorkspaceForm("local");
|
|
||||||
await creation.submitPrompt("Reply briefly.", "Create");
|
|
||||||
await creation.expectAssistantReply();
|
|
||||||
await waitForWorkspaceTabsVisible(page);
|
|
||||||
await splitIntoFourPanes(page);
|
|
||||||
await expectDividersTrackPointer(page);
|
|
||||||
await page
|
|
||||||
.getByTestId("workspace-explorer-toggle")
|
|
||||||
.filter({ visible: true })
|
|
||||||
.first()
|
|
||||||
.click();
|
|
||||||
await expect(page.getByTestId("workspace-explorer-sidebar")).toBeVisible();
|
|
||||||
await expectDividersTrackPointer(page, -DRAG_DISTANCE);
|
|
||||||
await page
|
|
||||||
.getByTestId("workspace-explorer-toggle")
|
|
||||||
.filter({ visible: true })
|
|
||||||
.first()
|
|
||||||
.click();
|
|
||||||
await expect(page.getByTestId("workspace-explorer-sidebar")).not.toBeVisible();
|
|
||||||
await page.reload();
|
|
||||||
await expect(splitDividers(page)).toHaveCount(3);
|
|
||||||
await expectDividersTrackPointer(page);
|
|
||||||
} finally {
|
|
||||||
await creation.cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,47 +1,7 @@
|
|||||||
require "fileutils"
|
|
||||||
require "tmpdir"
|
|
||||||
|
|
||||||
default_platform(:ios)
|
default_platform(:ios)
|
||||||
|
|
||||||
APP_IDENTIFIER = "sh.paseo"
|
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
|
platform :ios do
|
||||||
desc "Submit the latest TestFlight build to App Store Review"
|
desc "Submit the latest TestFlight build to App Store Review"
|
||||||
lane :submit_review do
|
lane :submit_review do
|
||||||
@@ -127,8 +87,7 @@ platform :ios do
|
|||||||
},
|
},
|
||||||
force: true,
|
force: true,
|
||||||
skip_binary_upload: true,
|
skip_binary_upload: true,
|
||||||
screenshots_path: stage_app_store_screenshots,
|
skip_screenshots: true,
|
||||||
overwrite_screenshots: true,
|
|
||||||
precheck_include_in_app_purchases: false,
|
precheck_include_in_app_purchases: false,
|
||||||
submission_information: {
|
submission_information: {
|
||||||
add_id_info_uses_idfa: false,
|
add_id_info_uses_idfa: false,
|
||||||
@@ -137,25 +96,3 @@ platform :ios do
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
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,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/app",
|
"name": "@getpaseo/app",
|
||||||
"version": "0.14.0",
|
"version": "0.14.0-beta.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "index.ts",
|
"main": "index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
const { withMainActivity } = require("expo/config-plugins");
|
|
||||||
|
|
||||||
module.exports = function withAndroidOrientation(config) {
|
|
||||||
return withMainActivity(config, (mod) => {
|
|
||||||
const activity = mod.modResults;
|
|
||||||
if (activity.language !== "kt") throw new Error("Expected Kotlin MainActivity");
|
|
||||||
const marker = " // Paseo orientation policy";
|
|
||||||
if (!activity.contents.includes(marker)) {
|
|
||||||
const anchor = " override fun onCreate(savedInstanceState: Bundle?)";
|
|
||||||
if (!activity.contents.includes(anchor)) throw new Error("MainActivity anchor missing");
|
|
||||||
activity.contents = activity.contents.replace(
|
|
||||||
anchor,
|
|
||||||
`${marker}
|
|
||||||
private fun updateOrientation(configuration: android.content.res.Configuration) {
|
|
||||||
val orientation = if (configuration.smallestScreenWidthDp >= 600)
|
|
||||||
android.content.pm.ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
|
|
||||||
else
|
|
||||||
android.content.pm.ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
|
|
||||||
if (requestedOrientation != orientation) requestedOrientation = orientation
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onConfigurationChanged(newConfig: android.content.res.Configuration) {
|
|
||||||
super.onConfigurationChanged(newConfig)
|
|
||||||
updateOrientation(newConfig)
|
|
||||||
}
|
|
||||||
|
|
||||||
${anchor}`,
|
|
||||||
);
|
|
||||||
if (!activity.contents.includes(" super.onCreate(null)"))
|
|
||||||
throw new Error("MainActivity onCreate missing");
|
|
||||||
activity.contents = activity.contents.replace(
|
|
||||||
" super.onCreate(null)",
|
|
||||||
" updateOrientation(resources.configuration)\n super.onCreate(null)",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return mod;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@@ -49,7 +49,7 @@ export function ResizeHandle({
|
|||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const finePointer = useHasFinePointer();
|
const finePointer = useHasFinePointer();
|
||||||
const pointerStatesRef = useRef(new Map<number, PointerState>());
|
const pointerStatesRef = useRef(new Map<number, PointerState>());
|
||||||
const touchStateRef = useRef<PointerState | null>(null);
|
const touchDragRef = useRef<ResizeHandleDrag | null>(null);
|
||||||
const cursorBeforeDragRef = useRef<string | null>(null);
|
const cursorBeforeDragRef = useRef<string | null>(null);
|
||||||
const hoverTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const hoverTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const [active, setActive] = useState(false);
|
const [active, setActive] = useState(false);
|
||||||
@@ -151,30 +151,23 @@ export function ResizeHandle({
|
|||||||
const touchGesture = useMemo(() => {
|
const touchGesture = useMemo(() => {
|
||||||
const gesture = Gesture.Pan()
|
const gesture = Gesture.Pan()
|
||||||
.runOnJS(true)
|
.runOnJS(true)
|
||||||
.onBegin((event) => {
|
.onBegin(() => setDragging(true))
|
||||||
setDragging(true);
|
.onStart(() => {
|
||||||
touchStateRef.current = {
|
touchDragRef.current = startResizeHandleDrag({
|
||||||
containerSize,
|
sizes,
|
||||||
pointerStart: direction === "horizontal" ? event.absoluteX : event.absoluteY,
|
index,
|
||||||
drag: startResizeHandleDrag({
|
preview: (nextSizes) => onPreviewResizeSplit(groupId, nextSizes),
|
||||||
sizes,
|
commit: (nextSizes) => onResizeSplit(groupId, nextSizes),
|
||||||
index,
|
});
|
||||||
preview: (nextSizes) => onPreviewResizeSplit(groupId, nextSizes),
|
|
||||||
commit: (nextSizes) => onResizeSplit(groupId, nextSizes),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
})
|
})
|
||||||
.onUpdate((event) => {
|
.onUpdate((event) => {
|
||||||
const state = touchStateRef.current;
|
if (containerSize <= 0) return;
|
||||||
if (!state || state.containerSize <= 0) return;
|
const translation = direction === "horizontal" ? event.translationX : event.translationY;
|
||||||
// Gesture Handler resets web translation when the pan activates. Measure from
|
touchDragRef.current?.move(translation / containerSize);
|
||||||
// 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(() => touchStateRef.current?.drag.finish())
|
.onEnd(() => touchDragRef.current?.finish())
|
||||||
.onFinalize(() => {
|
.onFinalize(() => {
|
||||||
touchStateRef.current = null;
|
touchDragRef.current = null;
|
||||||
setDragging(false);
|
setDragging(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -48,7 +48,6 @@ import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-
|
|||||||
import {
|
import {
|
||||||
collectAllPanes,
|
collectAllPanes,
|
||||||
createDefaultLayout,
|
createDefaultLayout,
|
||||||
selectExplorerSidebarPaneId,
|
|
||||||
useWorkspaceLayoutStore,
|
useWorkspaceLayoutStore,
|
||||||
type SplitNode,
|
type SplitNode,
|
||||||
type SplitPane,
|
type SplitPane,
|
||||||
@@ -184,8 +183,8 @@ export function SplitContainer({
|
|||||||
} | null>(null);
|
} | null>(null);
|
||||||
const maximizedPaneId =
|
const maximizedPaneId =
|
||||||
maximizedPane?.workspaceKey === workspaceKey ? maximizedPane.paneId : null;
|
maximizedPane?.workspaceKey === workspaceKey ? maximizedPane.paneId : null;
|
||||||
const explorerSidebarPaneId = useWorkspaceLayoutStore((state) =>
|
const explorerSidebarPaneId = useWorkspaceLayoutStore(
|
||||||
selectExplorerSidebarPaneId(state, workspaceKey),
|
(state) => state.explorerSidebarPaneIdByWorkspace[workspaceKey] ?? null,
|
||||||
);
|
);
|
||||||
|
|
||||||
const mainRoot = useMemo(
|
const mainRoot = useMemo(
|
||||||
|
|||||||
@@ -1,124 +0,0 @@
|
|||||||
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,7 +2,6 @@ import {
|
|||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
useId,
|
useId,
|
||||||
useLayoutEffect,
|
|
||||||
useMemo,
|
useMemo,
|
||||||
useState,
|
useState,
|
||||||
type ReactElement,
|
type ReactElement,
|
||||||
@@ -48,41 +47,56 @@ const contentEntering = new Keyframe({
|
|||||||
100: { opacity: 1, transform: [{ scale: 1 }] },
|
100: { opacity: 1, transform: [{ scale: 1 }] },
|
||||||
}).duration(CONTENT_ENTERING_DURATION_MS);
|
}).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({
|
const contentExiting = new Keyframe({
|
||||||
0: { opacity: 1, transform: [{ scale: 1 }] },
|
0: { opacity: 1, transform: [{ scale: 1 }] },
|
||||||
100: { opacity: 0, transform: [{ scale: 0.97 }] },
|
100: { opacity: 0, transform: [{ scale: 0.97 }] },
|
||||||
}).duration(100);
|
}).duration(100);
|
||||||
|
|
||||||
|
function releaseFixedMenuHeight(surfaceNativeID: string): void {
|
||||||
|
if (!isWeb) return;
|
||||||
|
document.getElementById(surfaceNativeID)?.style.removeProperty("height");
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* On web the surface enters through the Web Animations API rather than Reanimated. Reanimated's
|
* Reanimated's web entering animation leaves an inline height snapshot on the measured surface.
|
||||||
* web path snapshots an element's rect when a custom keyframe ends and, when it cleans the keyframe
|
* Once the menu is open, height must return to content-sized so rows can grow in place — and so
|
||||||
* up 750ms after the start, writes that rect back as inline top/left/width/height. A surface that
|
* a submenu page taller than the page it replaced is not clipped to the old height.
|
||||||
* 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.
|
* `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.
|
||||||
*/
|
*/
|
||||||
function useWebContentEntering({
|
function useReleaseFixedMenuHeight({
|
||||||
placed,
|
contentSize,
|
||||||
|
enabled,
|
||||||
surfaceNativeID,
|
surfaceNativeID,
|
||||||
|
revision,
|
||||||
}: {
|
}: {
|
||||||
placed: boolean;
|
contentSize: Size | null;
|
||||||
|
enabled: boolean;
|
||||||
surfaceNativeID: string;
|
surfaceNativeID: string;
|
||||||
|
revision?: string | number;
|
||||||
}): void {
|
}): void {
|
||||||
useLayoutEffect(() => {
|
useEffect(() => {
|
||||||
if (!isWeb || !placed || typeof document === "undefined") return undefined;
|
if (!enabled) return undefined;
|
||||||
const animation = document
|
|
||||||
.getElementById(surfaceNativeID)
|
const release = () => {
|
||||||
?.animate(WEB_CONTENT_ENTERING_KEYFRAMES, CONTENT_ENTERING_DURATION_MS);
|
releaseFixedMenuHeight(surfaceNativeID);
|
||||||
return () => animation?.cancel();
|
if (typeof requestAnimationFrame === "function") {
|
||||||
}, [placed, surfaceNativeID]);
|
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]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -210,6 +224,8 @@ export interface AnchoredSurfaceProps {
|
|||||||
fullWidth?: boolean;
|
fullWidth?: boolean;
|
||||||
horizontalPadding?: number;
|
horizontalPadding?: number;
|
||||||
scrollable?: boolean;
|
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. */
|
/** A submenu sits inside its parent's overlay and must not paint a second backdrop. */
|
||||||
backdrop?: boolean;
|
backdrop?: boolean;
|
||||||
/**
|
/**
|
||||||
@@ -243,6 +259,7 @@ export function AnchoredSurface({
|
|||||||
fullWidth = false,
|
fullWidth = false,
|
||||||
horizontalPadding = 16,
|
horizontalPadding = 16,
|
||||||
scrollable = false,
|
scrollable = false,
|
||||||
|
revision,
|
||||||
backdrop = true,
|
backdrop = true,
|
||||||
onPointerEnter,
|
onPointerEnter,
|
||||||
onPointerLeave,
|
onPointerLeave,
|
||||||
@@ -252,25 +269,33 @@ export function AnchoredSurface({
|
|||||||
}: AnchoredSurfaceProps): ReactElement | null {
|
}: AnchoredSurfaceProps): ReactElement | null {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const surfaceNativeID = useId();
|
const surfaceNativeID = useId();
|
||||||
const { position, actualPlacement, visibleContentSize, onContentLayout } = useAnchoredPosition({
|
const { position, actualPlacement, contentSize, visibleContentSize, onContentLayout } =
|
||||||
open,
|
useAnchoredPosition({
|
||||||
anchorRect,
|
open,
|
||||||
anchorRef,
|
anchorRect,
|
||||||
side,
|
anchorRef,
|
||||||
align,
|
side,
|
||||||
offset,
|
align,
|
||||||
scrollable,
|
offset,
|
||||||
maxHeight,
|
scrollable,
|
||||||
|
maxHeight,
|
||||||
|
});
|
||||||
|
|
||||||
|
useReleaseFixedMenuHeight({
|
||||||
|
contentSize,
|
||||||
|
enabled: open,
|
||||||
|
surfaceNativeID,
|
||||||
|
revision,
|
||||||
});
|
});
|
||||||
|
|
||||||
// The surface is placed once both measurements land. Until then it sits off-screen so it can
|
// The surface is placed once both measurements land. Until then it sits off-screen so it can
|
||||||
// be measured, and it must not animate: an entering animation that ran there would be over
|
// be measured, and it must not animate: Reanimated's web entering path snapshots the element's
|
||||||
// before anyone saw the menu. Remounting on placement starts the native animation from the
|
// rect when the animation ends and writes that rect back as inline top/left when it cleans the
|
||||||
// final position; web starts its own animation on placement.
|
// 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.
|
||||||
const placed = position !== null;
|
const placed = position !== null;
|
||||||
|
|
||||||
useWebContentEntering({ placed, surfaceNativeID });
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isWeb || !open || !placed || typeof document === "undefined") return undefined;
|
if (!isWeb || !open || !placed || typeof document === "undefined") return undefined;
|
||||||
const frame = requestAnimationFrame(() => {
|
const frame = requestAnimationFrame(() => {
|
||||||
@@ -358,7 +383,7 @@ export function AnchoredSurface({
|
|||||||
dataSet={surfaceDataSet}
|
dataSet={surfaceDataSet}
|
||||||
style={styles.content}
|
style={styles.content}
|
||||||
frameStyle={frameStyle}
|
frameStyle={frameStyle}
|
||||||
entering={placed ? nativeContentEntering : undefined}
|
entering={placed ? contentEntering : undefined}
|
||||||
exiting={
|
exiting={
|
||||||
!placed || isWeb || !onExited
|
!placed || isWeb || !onExited
|
||||||
? undefined
|
? undefined
|
||||||
|
|||||||
@@ -344,6 +344,7 @@ function MenuFlyout({
|
|||||||
maxHeight={maxHeight}
|
maxHeight={maxHeight}
|
||||||
scrollable={scrollable}
|
scrollable={scrollable}
|
||||||
backdrop={false}
|
backdrop={false}
|
||||||
|
revision={page.id}
|
||||||
onPointerEnter={cancelHoverClose}
|
onPointerEnter={cancelHoverClose}
|
||||||
onPointerLeave={handleHoverOut}
|
onPointerLeave={handleHoverOut}
|
||||||
testID={testID}
|
testID={testID}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/cli",
|
"name": "@getpaseo/cli",
|
||||||
"version": "0.14.0",
|
"version": "0.14.0-beta.2",
|
||||||
"description": "Paseo CLI - control your AI coding agents from the command line",
|
"description": "Paseo CLI - control your AI coding agents from the command line",
|
||||||
"bin": {
|
"bin": {
|
||||||
"paseo": "bin/paseo"
|
"paseo": "bin/paseo"
|
||||||
@@ -28,9 +28,9 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clack/prompts": "^1.0.0",
|
"@clack/prompts": "^1.0.0",
|
||||||
"@getpaseo/client": "0.14.0",
|
"@getpaseo/client": "0.14.0-beta.2",
|
||||||
"@getpaseo/protocol": "0.14.0",
|
"@getpaseo/protocol": "0.14.0-beta.2",
|
||||||
"@getpaseo/server": "0.14.0",
|
"@getpaseo/server": "0.14.0-beta.2",
|
||||||
"chalk": "^5.3.0",
|
"chalk": "^5.3.0",
|
||||||
"commander": "^12.0.0",
|
"commander": "^12.0.0",
|
||||||
"mime-types": "^2.1.35",
|
"mime-types": "^2.1.35",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/client",
|
"name": "@getpaseo/client",
|
||||||
"version": "0.14.0",
|
"version": "0.14.0-beta.2",
|
||||||
"description": "Paseo client SDK package",
|
"description": "Paseo client SDK package",
|
||||||
"files": [
|
"files": [
|
||||||
"dist",
|
"dist",
|
||||||
@@ -47,8 +47,8 @@
|
|||||||
"test": "vitest run"
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@getpaseo/protocol": "0.14.0",
|
"@getpaseo/protocol": "0.14.0-beta.2",
|
||||||
"@getpaseo/relay": "0.14.0",
|
"@getpaseo/relay": "0.14.0-beta.2",
|
||||||
"ws": "^8.20.0",
|
"ws": "^8.20.0",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export { createHostChannelTransport } from "../hosts/index.js";
|
|||||||
import { HostTransportBudget } from "./buffering.js";
|
import { HostTransportBudget } from "./buffering.js";
|
||||||
export { HostTransportBudget } from "./buffering.js";
|
export { HostTransportBudget } from "./buffering.js";
|
||||||
import { homedir } from "node:os";
|
import { homedir } from "node:os";
|
||||||
|
import type { Agent } from "node:http";
|
||||||
import { WebSocket } from "ws";
|
import { WebSocket } from "ws";
|
||||||
import { parseHostTarget, type HostTarget } from "@getpaseo/protocol/host-target";
|
import { parseHostTarget, type HostTarget } from "@getpaseo/protocol/host-target";
|
||||||
import { createSshTunnel, type SshTunnel } from "./ssh-tunnel.js";
|
import { createSshTunnel, type SshTunnel } from "./ssh-tunnel.js";
|
||||||
@@ -19,10 +20,15 @@ export { parseHostTarget };
|
|||||||
export type { SshTunnel } from "./ssh-tunnel.js";
|
export type { SshTunnel } from "./ssh-tunnel.js";
|
||||||
export { createSshTunnel, resolveSshFailureDetail } from "./ssh-tunnel.js";
|
export { createSshTunnel, resolveSshFailureDetail } from "./ssh-tunnel.js";
|
||||||
|
|
||||||
|
export interface HostTransportOptions {
|
||||||
|
webSocketAgent?: (url: string) => Agent | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/** A factory owns one attempt, including a fresh SSH process and its teardown. */
|
/** A factory owns one attempt, including a fresh SSH process and its teardown. */
|
||||||
export function createHostTransportFactory(
|
export function createHostTransportFactory(
|
||||||
connection: string,
|
connection: string,
|
||||||
budget = new HostTransportBudget(),
|
budget = new HostTransportBudget(),
|
||||||
|
options: HostTransportOptions = {},
|
||||||
): DaemonTransportFactory {
|
): DaemonTransportFactory {
|
||||||
const target = parseHostTarget(connection);
|
const target = parseHostTarget(connection);
|
||||||
// Rises to "protected" once the daemon negotiates it, so a later attempt
|
// Rises to "protected" once the daemon negotiates it, so a later attempt
|
||||||
@@ -68,12 +74,13 @@ export function createHostTransportFactory(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const socketPath = socketTarget.socketPath?.replace(/^~(?=\/)/, homedir());
|
const socketPath = socketTarget.socketPath?.replace(/^~(?=\/)/, homedir());
|
||||||
let factory = createWebSocketTransportFactory((url, options) => {
|
let factory = createWebSocketTransportFactory((url, socketOptions) => {
|
||||||
const socket = new WebSocket(
|
const socket = new WebSocket(
|
||||||
socketPath && url.startsWith("ws+unix:") ? `ws+unix://${socketPath}:/ws` : url,
|
socketPath && url.startsWith("ws+unix:") ? `ws+unix://${socketPath}:/ws` : url,
|
||||||
options?.protocols,
|
socketOptions?.protocols,
|
||||||
{
|
{
|
||||||
headers: options?.headers,
|
headers: socketOptions?.headers,
|
||||||
|
agent: socketPath ? undefined : options.webSocketAgent?.(url),
|
||||||
...(socketPath ? { socketPath } : {}),
|
...(socketPath ? { socketPath } : {}),
|
||||||
maxPayload: 100 * 1024 * 1024,
|
maxPayload: 100 * 1024 * 1024,
|
||||||
},
|
},
|
||||||
@@ -191,14 +198,15 @@ export function createHostTransportFactory(
|
|||||||
}
|
}
|
||||||
export function createHostClient(
|
export function createHostClient(
|
||||||
connection: string,
|
connection: string,
|
||||||
config: Omit<DaemonClientConfig, "url" | "transportFactory">,
|
config: Omit<DaemonClientConfig, "url" | "transportFactory"> & HostTransportOptions,
|
||||||
budget?: HostTransportBudget,
|
budget?: HostTransportBudget,
|
||||||
): DaemonClient {
|
): DaemonClient {
|
||||||
const target = parseHostTarget(connection);
|
const target = parseHostTarget(connection);
|
||||||
|
const { webSocketAgent, ...clientConfig } = config;
|
||||||
return new DaemonClient({
|
return new DaemonClient({
|
||||||
...config,
|
...clientConfig,
|
||||||
url: "ws://host-connector/ws",
|
url: "ws://host-connector/ws",
|
||||||
...(target.password ? { password: target.password } : {}),
|
...(target.password ? { password: target.password } : {}),
|
||||||
transportFactory: createHostTransportFactory(connection, budget),
|
transportFactory: createHostTransportFactory(connection, budget, { webSocketAgent }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,158 +0,0 @@
|
|||||||
import fs from "node:fs/promises";
|
|
||||||
import os from "node:os";
|
|
||||||
import path from "node:path";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
import { execFile } from "node:child_process";
|
|
||||||
import { promisify } from "node:util";
|
|
||||||
import electronPath from "electron";
|
|
||||||
import { build } from "esbuild";
|
|
||||||
|
|
||||||
export async function runAdaptiveThemeRegression(artifactDir) {
|
|
||||||
const { outputFiles } = await build({
|
|
||||||
stdin: {
|
|
||||||
resolveDir: fileURLToPath(new URL("../../app", import.meta.url)),
|
|
||||||
contents: `
|
|
||||||
import { createElement } from "react";
|
|
||||||
import { createRoot } from "react-dom/client";
|
|
||||||
import { StyleSheet, UnistylesRuntime, withUnistyles } from "react-native-unistyles";
|
|
||||||
StyleSheet.configure({
|
|
||||||
themes: {
|
|
||||||
dark: { foreground: "#fafafa", background: "#181b1a" },
|
|
||||||
light: { foreground: "#1a1a1e", background: "#ffffff" },
|
|
||||||
},
|
|
||||||
settings: { adaptiveThemes: true },
|
|
||||||
});
|
|
||||||
const Message = withUnistyles(({ color }) =>
|
|
||||||
createElement("p", { style: { color } }, "Theme-reactive message"));
|
|
||||||
createRoot(document.getElementById("root")).render(createElement(Message, {
|
|
||||||
style: { margin: 0 },
|
|
||||||
uniProps: theme => ({ color: theme.foreground }),
|
|
||||||
}));
|
|
||||||
window.readThemeForTest = () => UnistylesRuntime.themeName;
|
|
||||||
`,
|
|
||||||
},
|
|
||||||
bundle: true,
|
|
||||||
write: false,
|
|
||||||
format: "iife",
|
|
||||||
platform: "browser",
|
|
||||||
alias: { "react-native": "react-native-web" },
|
|
||||||
define: { __DEV__: "false" },
|
|
||||||
resolveExtensions: [".web.js", ".js", ".json"],
|
|
||||||
});
|
|
||||||
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-adaptive-theme-"));
|
|
||||||
try {
|
|
||||||
await fs.writeFile(path.join(directory, "renderer.js"), outputFiles[0].text);
|
|
||||||
await fs.writeFile(path.join(directory, "main.cjs"), `(${electronRegression.toString()})()`);
|
|
||||||
// A separate process avoids Playwright's focus emulation, which keeps hidden pages visible.
|
|
||||||
const env = { ...process.env };
|
|
||||||
delete env.ELECTRON_RUN_AS_NODE;
|
|
||||||
const args = [
|
|
||||||
...(process.platform === "linux" ? ["--no-sandbox"] : []),
|
|
||||||
path.join(directory, "main.cjs"),
|
|
||||||
artifactDir,
|
|
||||||
];
|
|
||||||
const command = process.platform === "linux" ? "xvfb-run" : electronPath;
|
|
||||||
const commandArgs =
|
|
||||||
process.platform === "linux"
|
|
||||||
? ["-a", "--server-args=-screen 0 1280x800x24", electronPath, ...args]
|
|
||||||
: args;
|
|
||||||
await promisify(execFile)(command, commandArgs, {
|
|
||||||
env,
|
|
||||||
timeout: 30_000,
|
|
||||||
});
|
|
||||||
return JSON.parse(await fs.readFile(path.join(artifactDir, "adaptive-theme.json"), "utf8"));
|
|
||||||
} finally {
|
|
||||||
await fs.rm(directory, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function electronRegression() {
|
|
||||||
const { app, BrowserWindow, nativeTheme } = require("electron");
|
|
||||||
const assert = require("node:assert/strict");
|
|
||||||
const files = require("node:fs");
|
|
||||||
const paths = require("node:path");
|
|
||||||
const { setTimeout: delay } = require("node:timers/promises");
|
|
||||||
app.setPath("userData", paths.join(__dirname, "profile"));
|
|
||||||
app.on("window-all-closed", () => {});
|
|
||||||
// Electron keeps Linux's --no-sandbox in argv; the artifact directory is always last.
|
|
||||||
const artifactDir = process.argv.at(-1);
|
|
||||||
let window;
|
|
||||||
const observations = [];
|
|
||||||
const colors = {
|
|
||||||
dark: { foreground: "rgb(250, 250, 250)", background: "rgb(24, 27, 26)" },
|
|
||||||
light: { foreground: "rgb(26, 26, 30)", background: "rgb(255, 255, 255)" },
|
|
||||||
};
|
|
||||||
async function read(expression) {
|
|
||||||
return window.webContents.executeJavaScript(expression);
|
|
||||||
}
|
|
||||||
async function expectValue(expression, expected) {
|
|
||||||
let actual;
|
|
||||||
for (let attempt = 0; attempt < 50; attempt++) {
|
|
||||||
actual = await read(expression);
|
|
||||||
if (actual === expected) return;
|
|
||||||
await delay(50);
|
|
||||||
}
|
|
||||||
assert.equal(actual, expected, expression);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await app.whenReady();
|
|
||||||
if (process.platform === "darwin") {
|
|
||||||
app.setActivationPolicy("accessory");
|
|
||||||
app.dock.hide();
|
|
||||||
}
|
|
||||||
for (const [from, to] of [
|
|
||||||
["dark", "light"],
|
|
||||||
["light", "dark"],
|
|
||||||
]) {
|
|
||||||
nativeTheme.themeSource = from;
|
|
||||||
window = new BrowserWindow({ width: 600, height: 200, show: false, skipTaskbar: true });
|
|
||||||
await window.loadURL(
|
|
||||||
"data:text/html," +
|
|
||||||
encodeURIComponent(`<!doctype html>
|
|
||||||
<style>body { background: var(--background); } p { font: 20px system-ui; }</style>
|
|
||||||
<div id="root"></div>`),
|
|
||||||
);
|
|
||||||
await read(files.readFileSync(paths.join(__dirname, "renderer.js"), "utf8"));
|
|
||||||
window.showInactive();
|
|
||||||
const foreground =
|
|
||||||
"document.querySelector('p') && getComputedStyle(document.querySelector('p')).color";
|
|
||||||
const background = "getComputedStyle(document.body).backgroundColor";
|
|
||||||
await expectValue(foreground, colors[from].foreground);
|
|
||||||
await expectValue(background, colors[from].background);
|
|
||||||
window.hide();
|
|
||||||
await expectValue("document.visibilityState", "hidden");
|
|
||||||
nativeTheme.themeSource = to;
|
|
||||||
await expectValue(`matchMedia('(prefers-color-scheme: ${to})').matches`, true);
|
|
||||||
// Reading the public runtime while hidden must not consume the pending theme notification.
|
|
||||||
assert.equal(await read("window.readThemeForTest()"), to);
|
|
||||||
window.showInactive();
|
|
||||||
await expectValue("document.visibilityState", "visible");
|
|
||||||
await expectValue(background, colors[to].background);
|
|
||||||
try {
|
|
||||||
await expectValue(foreground, colors[to].foreground);
|
|
||||||
} finally {
|
|
||||||
files.writeFileSync(
|
|
||||||
paths.join(artifactDir, `theme-${from}-to-${to}.png`),
|
|
||||||
(await window.webContents.capturePage()).toPNG(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
observations.push({
|
|
||||||
from,
|
|
||||||
to,
|
|
||||||
foreground: await read(foreground),
|
|
||||||
background: await read(background),
|
|
||||||
});
|
|
||||||
window.destroy();
|
|
||||||
window = undefined;
|
|
||||||
}
|
|
||||||
files.writeFileSync(
|
|
||||||
paths.join(artifactDir, "adaptive-theme.json"),
|
|
||||||
JSON.stringify(observations, null, 2),
|
|
||||||
);
|
|
||||||
app.exit(0);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
window?.destroy();
|
|
||||||
app.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -14,7 +14,6 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/
|
|||||||
import { chromium } from "playwright";
|
import { chromium } from "playwright";
|
||||||
import { hideDeveloperToasts } from "../../app/e2e/support/developer-ui.mjs";
|
import { hideDeveloperToasts } from "../../app/e2e/support/developer-ui.mjs";
|
||||||
import { runAppearanceFontSizeRegression } from "./appearance-font-size.electron.mjs";
|
import { runAppearanceFontSizeRegression } from "./appearance-font-size.electron.mjs";
|
||||||
import { runAdaptiveThemeRegression } from "./appearance-theme.electron.mjs";
|
|
||||||
import { runSettingsScrollRegression } from "./settings-scroll.electron.mjs";
|
import { runSettingsScrollRegression } from "./settings-scroll.electron.mjs";
|
||||||
import { runSettingsMemoryRegression } from "./settings-memory.electron.mjs";
|
import { runSettingsMemoryRegression } from "./settings-memory.electron.mjs";
|
||||||
|
|
||||||
@@ -1242,7 +1241,6 @@ async function main() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const adaptiveTheme = await runAdaptiveThemeRegression(artifactDir);
|
|
||||||
await runAppearanceFontSizeRegression(page);
|
await runAppearanceFontSizeRegression(page);
|
||||||
|
|
||||||
const callerAgentId = await createCallerAgent(daemonPort);
|
const callerAgentId = await createCallerAgent(daemonPort);
|
||||||
@@ -1267,12 +1265,7 @@ async function main() {
|
|||||||
artifactDir,
|
artifactDir,
|
||||||
});
|
});
|
||||||
const pluginLinks = await checkPluginLinks();
|
const pluginLinks = await checkPluginLinks();
|
||||||
writeJson(path.join(artifactDir, "result.json"), {
|
writeJson(path.join(artifactDir, "result.json"), { ...report, settingsMemory, pluginLinks });
|
||||||
...report,
|
|
||||||
settingsMemory,
|
|
||||||
pluginLinks,
|
|
||||||
adaptiveTheme,
|
|
||||||
});
|
|
||||||
console.log(
|
console.log(
|
||||||
`Browser desktop browser E2E passed: WebContents ${report.originalWebContentsId} remained ${report.finalWebContentsId}; viewport, inactive capture, focus continuity, list, snapshot, click, local-page selectors passed.`,
|
`Browser desktop browser E2E passed: WebContents ${report.originalWebContentsId} remained ${report.finalWebContentsId}; viewport, inactive capture, focus continuity, list, snapshot, click, local-page selectors passed.`,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/desktop",
|
"name": "@getpaseo/desktop",
|
||||||
"version": "0.14.0",
|
"version": "0.14.0-beta.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Paseo desktop app (Electron wrapper)",
|
"description": "Paseo desktop app (Electron wrapper)",
|
||||||
"homepage": "https://paseo.sh",
|
"homepage": "https://paseo.sh",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/expo-two-way-audio",
|
"name": "@getpaseo/expo-two-way-audio",
|
||||||
"version": "0.14.0",
|
"version": "0.14.0-beta.2",
|
||||||
"description": "Native module for two way audio streaming",
|
"description": "Native module for two way audio streaming",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"ExpoTwoWayAudio",
|
"ExpoTwoWayAudio",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/highlight",
|
"name": "@getpaseo/highlight",
|
||||||
"version": "0.14.0",
|
"version": "0.14.0-beta.2",
|
||||||
"files": [
|
"files": [
|
||||||
"dist",
|
"dist",
|
||||||
"!dist/**/*.map",
|
"!dist/**/*.map",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/plugin",
|
"name": "@getpaseo/plugin",
|
||||||
"version": "0.14.0",
|
"version": "0.14.0-beta.2",
|
||||||
"description": "Paseo plugin SDK package",
|
"description": "Paseo plugin SDK package",
|
||||||
"files": [
|
"files": [
|
||||||
"dist",
|
"dist",
|
||||||
@@ -77,8 +77,8 @@
|
|||||||
"use-sync-external-store": "^1.6.0"
|
"use-sync-external-store": "^1.6.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@getpaseo/client": "0.14.0",
|
"@getpaseo/client": "0.14.0-beta.2",
|
||||||
"@getpaseo/protocol": "0.14.0",
|
"@getpaseo/protocol": "0.14.0-beta.2",
|
||||||
"@types/node": "^20.9.0",
|
"@types/node": "^20.9.0",
|
||||||
"@types/react": "~19.2.0",
|
"@types/react": "~19.2.0",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
@@ -88,8 +88,8 @@
|
|||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@getpaseo/client": "0.14.0",
|
"@getpaseo/client": "0.14.0-beta.2",
|
||||||
"@getpaseo/protocol": "0.14.0",
|
"@getpaseo/protocol": "0.14.0-beta.2",
|
||||||
"react": ">=19.1.0 <20",
|
"react": ">=19.1.0 <20",
|
||||||
"react-native": ">=0.81.5",
|
"react-native": ">=0.81.5",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/protocol",
|
"name": "@getpaseo/protocol",
|
||||||
"version": "0.14.0",
|
"version": "0.14.0-beta.2",
|
||||||
"description": "Paseo shared protocol schemas and wire types",
|
"description": "Paseo shared protocol schemas and wire types",
|
||||||
"files": [
|
"files": [
|
||||||
"dist",
|
"dist",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/relay",
|
"name": "@getpaseo/relay",
|
||||||
"version": "0.14.0",
|
"version": "0.14.0-beta.2",
|
||||||
"description": "Paseo relay for bridging daemon and client connections",
|
"description": "Paseo relay for bridging daemon and client connections",
|
||||||
"files": [
|
"files": [
|
||||||
"dist",
|
"dist",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/server",
|
"name": "@getpaseo/server",
|
||||||
"version": "0.14.0",
|
"version": "0.14.0-beta.2",
|
||||||
"description": "Paseo backend server",
|
"description": "Paseo backend server",
|
||||||
"files": [
|
"files": [
|
||||||
"dist/server",
|
"dist/server",
|
||||||
@@ -89,7 +89,7 @@
|
|||||||
"speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts",
|
"speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts",
|
||||||
"test": "npm run test:unit && npm run test:integration",
|
"test": "npm run test:unit && npm run test:integration",
|
||||||
"test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"",
|
"test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"",
|
||||||
"test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/terminal-worker-lifecycle.e2e.test.ts src/server/workspace-create-worktree-source.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts src/server/plugins/lifecycle.e2e.test.ts src/server/connected-hosts/hosts.e2e.test.ts src/server/owned-subscriptions.e2e.test.ts && vitest run --maxWorkers=1 src/server/agent/providers/omp/history.e2e.test.ts src/server/daemon-e2e/git-operations.e2e.test.ts src/server/session.create-agent-worktree-autoarchive.e2e.test.ts src/server/agent/providers/opencode-bridge.local.e2e.test.ts src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"merges into|creates a worktree and auto-archives both|versioned runtime|oversized attachments|connected hosts|password-protected daemon refuses|replays selected OMP history\"",
|
"test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/terminal-worker-lifecycle.e2e.test.ts src/server/workspace-create-worktree-source.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts src/server/plugins/usage-sources.e2e.test.ts src/server/plugins/lifecycle.e2e.test.ts src/server/connected-hosts/hosts.e2e.test.ts && vitest run --maxWorkers=1 src/server/agent/providers/omp/history.e2e.test.ts src/server/daemon-e2e/git-operations.e2e.test.ts src/server/session.create-agent-worktree-autoarchive.e2e.test.ts src/server/agent/providers/opencode-bridge.local.e2e.test.ts src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"merges into|creates a worktree and auto-archives both|versioned runtime|oversized attachments|connected hosts|password-protected daemon refuses|replays selected OMP history\"",
|
||||||
"test:hub-cli-contract": "vitest run --maxWorkers=1 src/server/hub/hub-cli-contract.test.ts",
|
"test:hub-cli-contract": "vitest run --maxWorkers=1 src/server/hub/hub-cli-contract.test.ts",
|
||||||
"test:integration:all": "npm run test:e2e",
|
"test:integration:all": "npm run test:e2e",
|
||||||
"test:integration:real": "vitest run real.e2e.test.ts",
|
"test:integration:real": "vitest run real.e2e.test.ts",
|
||||||
@@ -107,11 +107,11 @@
|
|||||||
"@agentclientprotocol/sdk": "^0.17.1",
|
"@agentclientprotocol/sdk": "^0.17.1",
|
||||||
"@anthropic-ai/claude-agent-sdk": "0.3.246",
|
"@anthropic-ai/claude-agent-sdk": "0.3.246",
|
||||||
"@anthropic-ai/sdk": "^0.104.2",
|
"@anthropic-ai/sdk": "^0.104.2",
|
||||||
"@getpaseo/client": "0.14.0",
|
"@getpaseo/client": "0.14.0-beta.2",
|
||||||
"@getpaseo/highlight": "0.14.0",
|
"@getpaseo/highlight": "0.14.0-beta.2",
|
||||||
"@getpaseo/plugin": "0.14.0",
|
"@getpaseo/plugin": "0.14.0-beta.2",
|
||||||
"@getpaseo/protocol": "0.14.0",
|
"@getpaseo/protocol": "0.14.0-beta.2",
|
||||||
"@getpaseo/relay": "0.14.0",
|
"@getpaseo/relay": "0.14.0-beta.2",
|
||||||
"@isaacs/ttlcache": "^2.1.4",
|
"@isaacs/ttlcache": "^2.1.4",
|
||||||
"@modelcontextprotocol/sdk": "1.29.0",
|
"@modelcontextprotocol/sdk": "1.29.0",
|
||||||
"@opencode-ai/sdk": "1.14.46",
|
"@opencode-ai/sdk": "1.14.46",
|
||||||
@@ -124,6 +124,7 @@
|
|||||||
"esbuild": "^0.25.12",
|
"esbuild": "^0.25.12",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"fast-deep-equal": "^3.1.3",
|
"fast-deep-equal": "^3.1.3",
|
||||||
|
"https-proxy-agent": "^7.0.6",
|
||||||
"lru-cache": "^11.5.1",
|
"lru-cache": "^11.5.1",
|
||||||
"markdown-it": "^10.0.0",
|
"markdown-it": "^10.0.0",
|
||||||
"mnemonic-id": "^3.2.7",
|
"mnemonic-id": "^3.2.7",
|
||||||
@@ -141,6 +142,7 @@
|
|||||||
"strip-ansi": "^7.1.2",
|
"strip-ansi": "^7.1.2",
|
||||||
"tree-kill": "^1.2.2",
|
"tree-kill": "^1.2.2",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
|
"undici": "^7.24.8",
|
||||||
"uuid": "^9.0.1",
|
"uuid": "^9.0.1",
|
||||||
"which": "^5.0.0",
|
"which": "^5.0.0",
|
||||||
"ws": "^8.14.2",
|
"ws": "^8.14.2",
|
||||||
|
|||||||
@@ -956,131 +956,6 @@ 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 () => {
|
test("orders an accepted steer before output emitted while acknowledgement is pending", async () => {
|
||||||
const entered = deferred<void>();
|
const entered = deferred<void>();
|
||||||
const release = deferred<void>();
|
const release = deferred<void>();
|
||||||
@@ -8059,60 +7934,6 @@ test("global subscribers receive internal agent events only when they opt in", a
|
|||||||
unsubscribeOptedIn();
|
unsubscribeOptedIn();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("agent removal notifications respect scoped subscriptions and internal visibility", () => {
|
|
||||||
const manager = new AgentManager({ clients: {}, logger });
|
|
||||||
const removedId = "00000000-0000-4000-8000-000000000901";
|
|
||||||
const internalId = "00000000-0000-4000-8000-000000000902";
|
|
||||||
const otherId = "00000000-0000-4000-8000-000000000903";
|
|
||||||
const publicEvents: AgentManagerEvent[] = [];
|
|
||||||
const allEvents: AgentManagerEvent[] = [];
|
|
||||||
const scopedEvents: AgentManagerEvent[] = [];
|
|
||||||
const internalEvents: AgentManagerEvent[] = [];
|
|
||||||
const unrelatedEvents: AgentManagerEvent[] = [];
|
|
||||||
const releases = [
|
|
||||||
manager.subscribe((event) => publicEvents.push(event), { replayState: false }),
|
|
||||||
manager.subscribe((event) => allEvents.push(event), {
|
|
||||||
replayState: false,
|
|
||||||
includeInternal: true,
|
|
||||||
}),
|
|
||||||
manager.subscribe((event) => scopedEvents.push(event), {
|
|
||||||
replayState: false,
|
|
||||||
agentId: removedId,
|
|
||||||
}),
|
|
||||||
manager.subscribe((event) => internalEvents.push(event), {
|
|
||||||
replayState: false,
|
|
||||||
agentId: internalId,
|
|
||||||
}),
|
|
||||||
manager.subscribe((event) => unrelatedEvents.push(event), {
|
|
||||||
replayState: false,
|
|
||||||
agentId: otherId,
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
try {
|
|
||||||
manager.notifyAgentRemoved({ id: removedId, workspaceId: "workspace" });
|
|
||||||
manager.notifyAgentRemoved({ id: internalId, workspaceId: "workspace", internal: true });
|
|
||||||
const publicRemoval = {
|
|
||||||
type: "agent_removed",
|
|
||||||
agentId: removedId,
|
|
||||||
workspaceId: "workspace",
|
|
||||||
internal: false,
|
|
||||||
};
|
|
||||||
const internalRemoval = {
|
|
||||||
type: "agent_removed",
|
|
||||||
agentId: internalId,
|
|
||||||
workspaceId: "workspace",
|
|
||||||
internal: true,
|
|
||||||
};
|
|
||||||
expect(publicEvents).toEqual([publicRemoval]);
|
|
||||||
expect(allEvents).toEqual([publicRemoval, internalRemoval]);
|
|
||||||
expect(scopedEvents).toEqual([publicRemoval]);
|
|
||||||
expect(internalEvents).toEqual([internalRemoval]);
|
|
||||||
expect(unrelatedEvents).toEqual([]);
|
|
||||||
} finally {
|
|
||||||
for (const release of releases) release();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("getAgent returns internal agents by ID", async () => {
|
test("getAgent returns internal agents by ID", async () => {
|
||||||
const internalAgentId = "00000000-0000-4000-8000-000000000107";
|
const internalAgentId = "00000000-0000-4000-8000-000000000107";
|
||||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||||
|
|||||||
@@ -236,7 +236,6 @@ export type {
|
|||||||
|
|
||||||
export type AgentManagerEvent =
|
export type AgentManagerEvent =
|
||||||
| { type: "agent_state"; agent: ManagedAgent }
|
| { type: "agent_state"; agent: ManagedAgent }
|
||||||
| { type: "agent_removed"; agentId: string; workspaceId?: string; internal: boolean }
|
|
||||||
| { type: "provider_subagent"; event: ProviderSubagentStoreEvent }
|
| { type: "provider_subagent"; event: ProviderSubagentStoreEvent }
|
||||||
| { type: "timeline_replacement"; agentId: string; epoch: string }
|
| { type: "timeline_replacement"; agentId: string; epoch: string }
|
||||||
| {
|
| {
|
||||||
@@ -2735,7 +2734,7 @@ export class AgentManager {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
if (isStaleProviderSessionError(error)) {
|
if (isStaleProviderSessionError(error)) {
|
||||||
this.runs.settleForegroundStart(pendingRun, { status: "failed", error: error.message });
|
pendingRun.start = { status: "failed", error: error.message };
|
||||||
agent.pendingReplacement = false;
|
agent.pendingReplacement = false;
|
||||||
if (!agent.activeForegroundTurnId) agent.lifecycle = "idle";
|
if (!agent.activeForegroundTurnId) agent.lifecycle = "idle";
|
||||||
this.runs.settleForegroundRun(agentId, pendingRun.token);
|
this.runs.settleForegroundRun(agentId, pendingRun.token);
|
||||||
@@ -2743,24 +2742,20 @@ export class AgentManager {
|
|||||||
}
|
}
|
||||||
agent.pendingReplacement = false;
|
agent.pendingReplacement = false;
|
||||||
const errorMsg = error instanceof Error ? error.message : "Failed to start turn";
|
const errorMsg = error instanceof Error ? error.message : "Failed to start turn";
|
||||||
this.runs.settleForegroundStart(pendingRun, { status: "failed", error: errorMsg });
|
pendingRun.start = { status: "failed", error: errorMsg };
|
||||||
try {
|
// A terminal rejection belongs after the submitted prompt even though no provider turn exists.
|
||||||
// A terminal rejection belongs after the submitted prompt even though no provider turn exists.
|
if (options?.clientMessageId) {
|
||||||
if (options?.clientMessageId) {
|
this.recordSubmittedPrompt(agent, prompt, options.clientMessageId, "rejected", {
|
||||||
this.recordSubmittedPrompt(agent, prompt, options.clientMessageId, "rejected", {
|
messageId: options.clientMessageId,
|
||||||
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;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2837,7 +2832,7 @@ export class AgentManager {
|
|||||||
agent.pendingReplacement = false;
|
agent.pendingReplacement = false;
|
||||||
}
|
}
|
||||||
const turnStartedAt = new Date();
|
const turnStartedAt = new Date();
|
||||||
this.runs.settleForegroundStart(pendingRun, { status: "started", turnId });
|
pendingRun.start = { status: "started", turnId };
|
||||||
agent.activeForegroundTurnId = turnId;
|
agent.activeForegroundTurnId = turnId;
|
||||||
this.openActiveTurn(agent, turnId, turnStartedAt);
|
this.openActiveTurn(agent, turnId, turnStartedAt);
|
||||||
agent.lifecycle = "running";
|
agent.lifecycle = "running";
|
||||||
@@ -3046,9 +3041,6 @@ export class AgentManager {
|
|||||||
prompt: AgentPromptInput,
|
prompt: AgentPromptInput,
|
||||||
options?: AgentSteerOptions,
|
options?: AgentSteerOptions,
|
||||||
): Promise<ActiveTurnSteerDispatchResult> {
|
): 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 agent = this.requireSessionAgent(agentId);
|
||||||
const expectedTurnId = agent.activeForegroundTurnId ?? agent.activeTurnId;
|
const expectedTurnId = agent.activeForegroundTurnId ?? agent.activeTurnId;
|
||||||
if (!expectedTurnId) {
|
if (!expectedTurnId) {
|
||||||
@@ -3520,15 +3512,6 @@ export class AgentManager {
|
|||||||
await this.deleteCommittedTimeline(agentId);
|
await this.deleteCommittedTimeline(agentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
notifyAgentRemoved(agent: Pick<StoredAgentRecord, "id" | "workspaceId" | "internal">): void {
|
|
||||||
this.dispatch({
|
|
||||||
type: "agent_removed",
|
|
||||||
agentId: agent.id,
|
|
||||||
workspaceId: agent.workspaceId,
|
|
||||||
internal: agent.internal === true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteCommittedTimeline(agentId: string): Promise<void> {
|
async deleteCommittedTimeline(agentId: string): Promise<void> {
|
||||||
await this.durableTimelineStore?.deleteAgent(agentId);
|
await this.durableTimelineStore?.deleteAgent(agentId);
|
||||||
}
|
}
|
||||||
@@ -5386,7 +5369,7 @@ export class AgentManager {
|
|||||||
for (const subscriber of this.subscribers) {
|
for (const subscriber of this.subscribers) {
|
||||||
if (
|
if (
|
||||||
subscriber.agentId &&
|
subscriber.agentId &&
|
||||||
(event.type === "agent_stream" || event.type === "agent_removed") &&
|
event.type === "agent_stream" &&
|
||||||
subscriber.agentId !== event.agentId
|
subscriber.agentId !== event.agentId
|
||||||
) {
|
) {
|
||||||
continue;
|
continue;
|
||||||
@@ -5422,7 +5405,6 @@ export class AgentManager {
|
|||||||
|
|
||||||
private eventBelongsToInternalAgent(event: AgentManagerEvent): boolean {
|
private eventBelongsToInternalAgent(event: AgentManagerEvent): boolean {
|
||||||
if (event.type === "agent_state") return event.agent.internal === true;
|
if (event.type === "agent_state") return event.agent.internal === true;
|
||||||
if (event.type === "agent_removed") return event.internal;
|
|
||||||
if (event.type === "agent_stream") return this.agents.get(event.agentId)?.internal === true;
|
if (event.type === "agent_stream") return this.agents.get(event.agentId)?.internal === true;
|
||||||
if (event.type !== "provider_subagent") return false;
|
if (event.type !== "provider_subagent") return false;
|
||||||
const parentAgentId =
|
const parentAgentId =
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
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,18 +10,14 @@ export interface ForegroundTurnWaiter {
|
|||||||
resolveSettled: () => void;
|
resolveSettled: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ForegroundRunStart =
|
|
||||||
| { status: "pending" }
|
|
||||||
| { status: "started"; turnId: string }
|
|
||||||
| { status: "failed"; error: string };
|
|
||||||
|
|
||||||
export interface PendingForegroundRun {
|
export interface PendingForegroundRun {
|
||||||
token: string;
|
token: string;
|
||||||
kind: "foreground";
|
kind: "foreground";
|
||||||
stagedEvents: AgentStreamEvent[];
|
stagedEvents: AgentStreamEvent[];
|
||||||
start: ForegroundRunStart;
|
start:
|
||||||
turnStartedPromise: Promise<void>;
|
| { status: "pending" }
|
||||||
resolveTurnStarted: () => void;
|
| { status: "started"; turnId: string }
|
||||||
|
| { status: "failed"; error: string };
|
||||||
settled: boolean;
|
settled: boolean;
|
||||||
settledPromise: Promise<void>;
|
settledPromise: Promise<void>;
|
||||||
resolveSettled: () => void;
|
resolveSettled: () => void;
|
||||||
@@ -58,28 +54,6 @@ export class AgentRunState {
|
|||||||
return run?.kind === "foreground" ? run : null;
|
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 {
|
hasPendingRun(agentId: string): boolean {
|
||||||
return this.getPendingRun(agentId) !== null;
|
return this.getPendingRun(agentId) !== null;
|
||||||
}
|
}
|
||||||
@@ -303,16 +277,10 @@ export class ForegroundTurnStream {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createPendingForegroundRun(): PendingForegroundRun {
|
function createPendingForegroundRun(): PendingForegroundRun {
|
||||||
let resolveTurnStarted!: () => void;
|
|
||||||
const turnStartedPromise = new Promise<void>((resolvePromise) => {
|
|
||||||
resolveTurnStarted = resolvePromise;
|
|
||||||
});
|
|
||||||
return {
|
return {
|
||||||
...createTrackedRunState(),
|
...createTrackedRunState(),
|
||||||
kind: "foreground",
|
kind: "foreground",
|
||||||
start: { status: "pending" },
|
start: { status: "pending" },
|
||||||
turnStartedPromise,
|
|
||||||
resolveTurnStarted,
|
|
||||||
stagedEvents: [],
|
stagedEvents: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import { execFile } from "node:child_process";
|
import { measureMainThreadWork } from "../../test-utils/main-thread-work.js";
|
||||||
import { promisify } from "node:util";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { selectReferenceProjectedTimelinePage } from "./test-utils/timeline-page-reference.js";
|
import { selectReferenceProjectedTimelinePage } from "./test-utils/timeline-page-reference.js";
|
||||||
import type { AgentTimelineItem } from "./agent-sdk-types.js";
|
import type { AgentTimelineItem } from "./agent-sdk-types.js";
|
||||||
@@ -253,52 +251,23 @@ it("includes transitive tool updates in a contiguous projected tail", () => {
|
|||||||
expect(tail).toMatchObject({ startSeq: 1, endSeq: 6, hasOlder: false });
|
expect(tail).toMatchObject({ startSeq: 1, endSeq: 6, hasOlder: false });
|
||||||
});
|
});
|
||||||
|
|
||||||
async function measureTimelineTail(wholeTurns: boolean) {
|
|
||||||
const fixture = fileURLToPath(
|
|
||||||
new URL("./test-utils/timeline-responsiveness.ts", import.meta.url),
|
|
||||||
);
|
|
||||||
const { stdout, stderr } = await promisify(execFile)(
|
|
||||||
process.execPath,
|
|
||||||
["--expose-gc", "--import", "tsx", fixture, String(wholeTurns)],
|
|
||||||
{ timeout: 15_000 },
|
|
||||||
);
|
|
||||||
process.stderr.write(stderr);
|
|
||||||
return JSON.parse(stdout) as {
|
|
||||||
result: ReturnType<InMemoryAgentTimelineStore["fetch"]>;
|
|
||||||
blockingMs: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
it("excludes unrelated test-runner CPU from the timeline fetch budget", async () => {
|
|
||||||
let unrelatedWork: ReturnType<typeof setImmediate>;
|
|
||||||
const burnCpu = () => {
|
|
||||||
const threadCpuUsage: (previous?: NodeJS.CpuUsage) => NodeJS.CpuUsage = Reflect.get(
|
|
||||||
process,
|
|
||||||
"threadCpuUsage",
|
|
||||||
);
|
|
||||||
const start = threadCpuUsage();
|
|
||||||
let elapsed: NodeJS.CpuUsage;
|
|
||||||
do {
|
|
||||||
elapsed = threadCpuUsage(start);
|
|
||||||
} while (elapsed.user + elapsed.system < 80_000);
|
|
||||||
unrelatedWork = setImmediate(burnCpu);
|
|
||||||
};
|
|
||||||
unrelatedWork = setImmediate(burnCpu);
|
|
||||||
try {
|
|
||||||
const { result: tail, blockingMs } = await measureTimelineTail(false);
|
|
||||||
expect(tail.rows).toHaveLength(200);
|
|
||||||
expect(tail.startSeq).toBe(99_801);
|
|
||||||
expect(tail.endSeq).toBe(100_000);
|
|
||||||
expect(blockingMs).toBeLessThan(50);
|
|
||||||
} finally {
|
|
||||||
clearImmediate(unrelatedWork);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([false, true])(
|
it.each([false, true])(
|
||||||
"serves tail 200 from 100,000 retained rows without stalling the loop (wholeTurns=%s)",
|
"serves tail 200 from 100,000 retained rows without stalling the loop (wholeTurns=%s)",
|
||||||
async (wholeTurns) => {
|
async (wholeTurns) => {
|
||||||
const { result: tail, blockingMs } = await measureTimelineTail(wholeTurns);
|
const store = new InMemoryAgentTimelineStore();
|
||||||
|
store.initialize("large");
|
||||||
|
for (let index = 0; index < 100_000; index++) {
|
||||||
|
store.append("large", { type: "user_message", text: String(index) });
|
||||||
|
}
|
||||||
|
const { result: tail, blockingMs } = await measureMainThreadWork(
|
||||||
|
`timeline tail main-thread work (wholeTurns=${wholeTurns})`,
|
||||||
|
() => {
|
||||||
|
let page = store.fetch("large", { limit: 200, wholeTurns });
|
||||||
|
for (let repeat = 1; repeat < 8; repeat++)
|
||||||
|
page = store.fetch("large", { limit: 200, wholeTurns });
|
||||||
|
return page;
|
||||||
|
},
|
||||||
|
);
|
||||||
expect(tail.rows).toHaveLength(200);
|
expect(tail.rows).toHaveLength(200);
|
||||||
expect(tail.startSeq).toBe(99_801);
|
expect(tail.startSeq).toBe(99_801);
|
||||||
expect(tail.endSeq).toBe(100_000);
|
expect(tail.endSeq).toBe(100_000);
|
||||||
|
|||||||
@@ -30,20 +30,16 @@ function createQueryMock(events: unknown[]): Query {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("Claude SDK env", () => {
|
describe("Claude SDK env", () => {
|
||||||
test.each(
|
test.each(["node", "bun", process.execPath])(
|
||||||
["node", "bun", process.execPath].flatMap((command) =>
|
"identifies the spawned %s process as Paseo after clearing parent session env",
|
||||||
[undefined, "Paseo", "custom-client"].map((entrypoint) => ({ command, entrypoint })),
|
async (command) => {
|
||||||
),
|
|
||||||
)(
|
|
||||||
"preserves explicit launch identity $entrypoint in $command while clearing inherited session env",
|
|
||||||
async ({ command, entrypoint }) => {
|
|
||||||
let output = "";
|
let output = "";
|
||||||
let exited = Promise.resolve<unknown[]>([]);
|
let exited = Promise.resolve<unknown[]>([]);
|
||||||
const query = claudeQuery(
|
const query = claudeQuery(
|
||||||
{ prompt: "", options: {} },
|
{ prompt: "", options: {} },
|
||||||
{
|
{
|
||||||
runtimeSettings: { env: { CLAUDE_CODE_ENTRYPOINT: "sdk-ts" } },
|
runtimeSettings: { env: { CLAUDE_CODE_ENTRYPOINT: "sdk-ts" } },
|
||||||
launchEnv: entrypoint === undefined ? {} : { CLAUDE_CODE_ENTRYPOINT: entrypoint },
|
launchEnv: { CLAUDE_CODE_ENTRYPOINT: "sdk-cli" },
|
||||||
queryFactory: ({ options }) => {
|
queryFactory: ({ options }) => {
|
||||||
if (!options.spawnClaudeCodeProcess) {
|
if (!options.spawnClaudeCodeProcess) {
|
||||||
throw new Error("Missing Claude process launcher");
|
throw new Error("Missing Claude process launcher");
|
||||||
@@ -69,10 +65,7 @@ describe("Claude SDK env", () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
expect(await exited).toEqual([0, null]);
|
expect(await exited).toEqual([0, null]);
|
||||||
expect(JSON.parse(output)).toEqual({
|
expect(JSON.parse(output)).toEqual({ entrypoint: "Paseo", parentSession: null });
|
||||||
...(entrypoint === undefined ? {} : { entrypoint }),
|
|
||||||
parentSession: null,
|
|
||||||
});
|
|
||||||
} finally {
|
} finally {
|
||||||
query.close();
|
query.close();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,9 +73,8 @@ function applyRuntimeSettingsToClaudeOptions(
|
|||||||
runtimeSettings,
|
runtimeSettings,
|
||||||
overlays: [launchEnv],
|
overlays: [launchEnv],
|
||||||
});
|
});
|
||||||
// Session-open hooks may explicitly supply a client identity. Keep inherited
|
// Set our identity after sanitization clears the parent session's entrypoint.
|
||||||
// SDK/parent identities cleared while honoring this launch-only contribution.
|
providerEnvSpec.envOverlay.CLAUDE_CODE_ENTRYPOINT = "Paseo";
|
||||||
providerEnvSpec.envOverlay.CLAUDE_CODE_ENTRYPOINT = launchEnv?.CLAUDE_CODE_ENTRYPOINT;
|
|
||||||
const providerEnv = createExternalProcessEnv(spawnOptions.env, providerEnvSpec.envOverlay);
|
const providerEnv = createExternalProcessEnv(spawnOptions.env, providerEnvSpec.envOverlay);
|
||||||
const selfNodeCommand = isDefaultRuntime
|
const selfNodeCommand = isDefaultRuntime
|
||||||
? buildSelfNodeCommand(resolved.args, providerEnv)
|
? buildSelfNodeCommand(resolved.args, providerEnv)
|
||||||
|
|||||||
@@ -226,64 +226,6 @@ function deferred<T>() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("Codex active-turn steering admission", () => {
|
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 () => {
|
test("a steer without the clearing contract leaves permissions open", async () => {
|
||||||
const appServer = createFakeCodexAppServer({
|
const appServer = createFakeCodexAppServer({
|
||||||
"turn/steer": () => ({ turn: { id: "native-A" } }),
|
"turn/steer": () => ({ turn: { id: "native-A" } }),
|
||||||
|
|||||||
@@ -4456,18 +4456,9 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
): Promise<SteerResult> {
|
): Promise<SteerResult> {
|
||||||
const client = this.client;
|
const client = this.client;
|
||||||
const threadId = this.currentThreadId;
|
const threadId = this.currentThreadId;
|
||||||
let nativeTurnId = this.currentTurnId;
|
const nativeTurnId = this.currentTurnId;
|
||||||
const foregroundTurnId = this.activeForegroundTurnId;
|
const foregroundTurnId = this.activeForegroundTurnId;
|
||||||
if (!client || !threadId || foregroundTurnId !== options.expectedTurnId) {
|
if (!client || !threadId || !nativeTurnId || 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" };
|
return { status: "unavailable" };
|
||||||
}
|
}
|
||||||
if (await this.resolveSlashCommandInvocation(prompt)) return { status: "unavailable" };
|
if (await this.resolveSlashCommandInvocation(prompt)) return { status: "unavailable" };
|
||||||
|
|||||||
-8
@@ -1,8 +0,0 @@
|
|||||||
{"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"}
|
|
||||||
-263
@@ -1,263 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
-8
@@ -1,8 +0,0 @@
|
|||||||
{"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"}
|
|
||||||
-156
@@ -1,156 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
-336
@@ -1,336 +0,0 @@
|
|||||||
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",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
-119
@@ -1,119 +0,0 @@
|
|||||||
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,6 +1,5 @@
|
|||||||
import { piAskUser } from "./pi-ask-user/index.js";
|
import { piAskUser } from "./pi-ask-user/index.js";
|
||||||
import { piSubagents } from "./pi-subagents/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 { piMcpAdapter } from "./pi-mcp-adapter/index.js";
|
||||||
import { rpivTodo } from "./rpiv-todo/index.js";
|
import { rpivTodo } from "./rpiv-todo/index.js";
|
||||||
import { piExampleTodo } from "./pi-example-todo/index.js";
|
import { piExampleTodo } from "./pi-example-todo/index.js";
|
||||||
@@ -11,7 +10,6 @@ import { rpivAskUserQuestion } from "./rpiv-ask-user-question/index.js";
|
|||||||
|
|
||||||
export const piExtensions = [
|
export const piExtensions = [
|
||||||
piAskUser, // Dialog correlation
|
piAskUser, // Dialog correlation
|
||||||
edxethPiSubagents, // Structured results before the generic subagent tool matcher
|
|
||||||
piSubagents, // Delegation calls
|
piSubagents, // Delegation calls
|
||||||
tintinwebPiSubagents,
|
tintinwebPiSubagents,
|
||||||
gotgenesPiSubagents,
|
gotgenesPiSubagents,
|
||||||
|
|||||||
@@ -184,24 +184,17 @@ test("repeated content is reused without rewriting the attachment", async () =>
|
|||||||
test("64 MiB materialization leaves the event loop responsive and preserves bytes", async () => {
|
test("64 MiB materialization leaves the event loop responsive and preserves bytes", async () => {
|
||||||
const bytes = Buffer.alloc(64 * 1024 * 1024, 37);
|
const bytes = Buffer.alloc(64 * 1024 * 1024, 37);
|
||||||
const data = bytes.toString("base64");
|
const data = bytes.toString("base64");
|
||||||
// Main-thread CPU time between ticks bounds the decode and hash work done on the loop;
|
let previous = performance.now();
|
||||||
// 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;
|
let longest = 0;
|
||||||
const probe = setInterval(() => {
|
const probe = setInterval(() => {
|
||||||
const now = threadCpuMs();
|
const now = performance.now();
|
||||||
longest = Math.max(longest, now - previous);
|
longest = Math.max(longest, now - previous);
|
||||||
previous = now;
|
previous = now;
|
||||||
}, 1);
|
}, 1);
|
||||||
try {
|
try {
|
||||||
const result = await materializeProviderImage({ data, mimeType: "image/png" });
|
const result = await materializeProviderImage({ data, mimeType: "image/png" });
|
||||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||||
console.log(`64 MiB attachment longest blocking stretch: ${longest.toFixed(2)} ms CPU`);
|
console.log(`64 MiB attachment longest loop gap: ${longest.toFixed(2)} ms`);
|
||||||
expect(longest).toBeLessThan(50);
|
expect(longest).toBeLessThan(50);
|
||||||
expect(readFileSync(result.path).equals(bytes)).toBe(true);
|
expect(readFileSync(result.path).equals(bytes)).toBe(true);
|
||||||
rmSync(result.path, { force: true });
|
rmSync(result.path, { force: true });
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
import { setImmediate as nextTurn } from "node:timers/promises";
|
|
||||||
import { measureMainThreadWork } from "../../../test-utils/main-thread-work.js";
|
|
||||||
import { InMemoryAgentTimelineStore } from "../agent-timeline-store.js";
|
|
||||||
|
|
||||||
// Measure a cold fetch on its own thread, without Vitest reporting or callbacks.
|
|
||||||
const wholeTurns = process.argv[2] === "true";
|
|
||||||
const store = new InMemoryAgentTimelineStore();
|
|
||||||
store.initialize("large");
|
|
||||||
for (let index = 0; index < 100_000; index++) {
|
|
||||||
store.append("large", { type: "user_message", text: String(index) });
|
|
||||||
}
|
|
||||||
// Finish fixture allocation and collect its temporary garbage before measuring.
|
|
||||||
globalThis.gc!();
|
|
||||||
await nextTurn();
|
|
||||||
const measured = await measureMainThreadWork(
|
|
||||||
`timeline tail main-thread work (wholeTurns=${wholeTurns})`,
|
|
||||||
() => {
|
|
||||||
let page = store.fetch("large", { limit: 200, wholeTurns });
|
|
||||||
for (let repeat = 1; repeat < 8; repeat++)
|
|
||||||
page = store.fetch("large", { limit: 200, wholeTurns });
|
|
||||||
return page;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
process.stdout.write(JSON.stringify(measured));
|
|
||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
type HostUpdate,
|
type HostUpdate,
|
||||||
} from "@getpaseo/protocol/hosts";
|
} from "@getpaseo/protocol/hosts";
|
||||||
import type { DaemonPermission, WSHelloMessage } from "@getpaseo/protocol/messages";
|
import type { DaemonPermission, WSHelloMessage } from "@getpaseo/protocol/messages";
|
||||||
|
import { webSocketProxyAgent } from "../network/index.js";
|
||||||
|
|
||||||
export class HostError extends Error {
|
export class HostError extends Error {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -242,6 +243,7 @@ export class ConnectedHosts {
|
|||||||
{
|
{
|
||||||
clientId: `host-link-${this.self.serverId}-${row.registrationId}`,
|
clientId: `host-link-${this.self.serverId}-${row.registrationId}`,
|
||||||
connectTimeoutMs: 10000,
|
connectTimeoutMs: 10000,
|
||||||
|
webSocketAgent: webSocketProxyAgent,
|
||||||
},
|
},
|
||||||
this.budget,
|
this.budget,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,80 +0,0 @@
|
|||||||
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);
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { appendFileSync, mkdirSync } from "node:fs";
|
import { appendFileSync, mkdirSync } from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import { initializeEnvironmentProxy } from "./network/index.js";
|
||||||
import { createPaseoDaemon, formatListenTarget } from "./bootstrap.js";
|
import { createPaseoDaemon, formatListenTarget } from "./bootstrap.js";
|
||||||
import { loadConfig } from "./config.js";
|
import { loadConfig } from "./config.js";
|
||||||
import { resolvePaseoHome } from "./paseo-home.js";
|
import { resolvePaseoHome } from "./paseo-home.js";
|
||||||
@@ -132,6 +133,11 @@ function applyCliFlagOverrides(config: ReturnType<typeof loadConfig>): void {
|
|||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const { paseoHome, logger, config } = bootstrapFromEnvironment();
|
const { paseoHome, logger, config } = bootstrapFromEnvironment();
|
||||||
|
for (const variable of initializeEnvironmentProxy()) {
|
||||||
|
writeWorkerLifecycleLog(paseoHome, "Ignoring invalid or unsupported HTTP proxy variable", {
|
||||||
|
variable,
|
||||||
|
});
|
||||||
|
}
|
||||||
let daemon: Awaited<ReturnType<typeof createPaseoDaemon>> | null = null;
|
let daemon: Awaited<ReturnType<typeof createPaseoDaemon>> | null = null;
|
||||||
let shutdownPromise: Promise<number> | null = null;
|
let shutdownPromise: Promise<number> | null = null;
|
||||||
let exitHookInstalled = false;
|
let exitHookInstalled = false;
|
||||||
|
|||||||
@@ -248,7 +248,7 @@ test("ordinary Hub requests survive daemon restart and restore an archived works
|
|||||||
}),
|
}),
|
||||||
).toMatchObject({ payload: { agentId, accepted: true } });
|
).toMatchObject({ payload: { agentId, accepted: true } });
|
||||||
expect(hub.providerPromptTexts().filter((text) => text === "follow up")).toHaveLength(1);
|
expect(hub.providerPromptTexts().filter((text) => text === "follow up")).toHaveLength(1);
|
||||||
});
|
}, 20_000);
|
||||||
|
|
||||||
test("Hub completes the standard hello before rejecting a second hello", async () => {
|
test("Hub completes the standard hello before rejecting a second hello", async () => {
|
||||||
const hub = await launchRelationship();
|
const hub = await launchRelationship();
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { WebSocket } from "ws";
|
import { WebSocket } from "ws";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { webSocketProxyAgent } from "../network/index.js";
|
||||||
import type { WebSocketLike } from "../websocket-server.js";
|
import type { WebSocketLike } from "../websocket-server.js";
|
||||||
|
|
||||||
const SOCKET_PING_INTERVAL_MS = 10_000;
|
const SOCKET_PING_INTERVAL_MS = 10_000;
|
||||||
@@ -166,6 +167,7 @@ export class DirectHubRelationshipRemote implements HubRelationshipRemote {
|
|||||||
openSocket(input: HubSocketCredentials, events: HubSocketEvents): HubSocketConnection {
|
openSocket(input: HubSocketCredentials, events: HubSocketEvents): HubSocketConnection {
|
||||||
let sessionProtocol: "legacy" | "session-v1" = "legacy";
|
let sessionProtocol: "legacy" | "session-v1" = "legacy";
|
||||||
const socket = new WebSocket(input.webSocketUrl, {
|
const socket = new WebSocket(input.webSocketUrl, {
|
||||||
|
agent: webSocketProxyAgent(input.webSocketUrl),
|
||||||
handshakeTimeout: this.requestTimeoutMs,
|
handshakeTimeout: this.requestTimeoutMs,
|
||||||
headers: {
|
headers: {
|
||||||
authorization: `Bearer ${input.credential}`,
|
authorization: `Bearer ${input.credential}`,
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import type { Agent } from "node:http";
|
||||||
|
import { EnvHttpProxyAgent, setGlobalDispatcher } from "undici";
|
||||||
|
import { HttpsProxyAgent } from "https-proxy-agent";
|
||||||
|
|
||||||
|
interface ProxyEnvironment {
|
||||||
|
httpProxy: string;
|
||||||
|
httpsProxy: string;
|
||||||
|
noProxy: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let environment = readEnvironment(process.env).environment;
|
||||||
|
|
||||||
|
function readEnvironment(env: NodeJS.ProcessEnv): {
|
||||||
|
environment: ProxyEnvironment;
|
||||||
|
ignored: string[];
|
||||||
|
} {
|
||||||
|
// process.env lookups are case-insensitive on Windows. Read each actual entry once.
|
||||||
|
const values = new Map(Object.entries(env));
|
||||||
|
const ignored: string[] = [];
|
||||||
|
function proxy(variable: string): string | undefined {
|
||||||
|
const value = values.get(variable);
|
||||||
|
if (value === undefined) return undefined;
|
||||||
|
if (!value.trim()) return undefined;
|
||||||
|
try {
|
||||||
|
const url = new URL(value.includes("://") ? value : `http://${value}`);
|
||||||
|
if (url.protocol !== "http:" && url.protocol !== "https:")
|
||||||
|
throw new Error("Unsupported proxy protocol");
|
||||||
|
decodeURIComponent(url.username);
|
||||||
|
decodeURIComponent(url.password);
|
||||||
|
return url.href;
|
||||||
|
} catch {
|
||||||
|
ignored.push(variable);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const httpProxy = proxy("http_proxy") ?? proxy("HTTP_PROXY") ?? "";
|
||||||
|
return {
|
||||||
|
environment: {
|
||||||
|
httpProxy,
|
||||||
|
httpsProxy: proxy("https_proxy") ?? proxy("HTTPS_PROXY") ?? httpProxy,
|
||||||
|
noProxy: values.get("no_proxy") ?? values.get("NO_PROXY") ?? "",
|
||||||
|
},
|
||||||
|
ignored,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function bypassList(noProxy: string): string {
|
||||||
|
return noProxy.trim() === "*" ? "*" : `${noProxy},localhost,127.0.0.1,[::1],::1`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Install once at startup; return ignored variable names for logging without exposing credentials. */
|
||||||
|
export function initializeEnvironmentProxy(env: NodeJS.ProcessEnv = process.env): string[] {
|
||||||
|
const resolved = readEnvironment(env);
|
||||||
|
environment = resolved.environment;
|
||||||
|
setGlobalDispatcher(
|
||||||
|
new EnvHttpProxyAgent({
|
||||||
|
httpProxy: environment.httpProxy,
|
||||||
|
httpsProxy: environment.httpsProxy,
|
||||||
|
noProxy: bypassList(environment.noProxy),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return resolved.ignored;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ws uses Node HTTP agents rather than the fetch dispatcher. */
|
||||||
|
export function webSocketProxyAgent(address: string): Agent | undefined {
|
||||||
|
const url = new URL(address);
|
||||||
|
const hostname = url.hostname.toLowerCase();
|
||||||
|
const port = url.port || (url.protocol === "wss:" ? "443" : "80");
|
||||||
|
const entries = bypassList(environment.noProxy)
|
||||||
|
.toLowerCase()
|
||||||
|
.split(/[,\s]+/);
|
||||||
|
if (
|
||||||
|
entries.some((entry) => {
|
||||||
|
if (entry === "*") return true;
|
||||||
|
const match = entry.match(/^(.+):(\d+)$/);
|
||||||
|
if (match && match[2] !== port) return false;
|
||||||
|
const host = (match ? match[1]! : entry).replace(/^\*?\./, "");
|
||||||
|
return host !== "" && (hostname === host || hostname.endsWith(`.${host}`));
|
||||||
|
})
|
||||||
|
)
|
||||||
|
return undefined;
|
||||||
|
const proxy = url.protocol === "wss:" ? environment.httpsProxy : environment.httpProxy;
|
||||||
|
return proxy ? new HttpsProxyAgent(proxy) : undefined;
|
||||||
|
}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
import { fork } from "node:child_process";
|
||||||
|
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { createServer, type Server } from "node:http";
|
||||||
|
import { once } from "node:events";
|
||||||
|
import { afterEach, expect, test } from "vitest";
|
||||||
|
import { WebSocket, WebSocketServer } from "ws";
|
||||||
|
import { getGlobalDispatcher, setGlobalDispatcher } from "undici";
|
||||||
|
import { initializeEnvironmentProxy, webSocketProxyAgent } from "./index.js";
|
||||||
|
import { ConnectedHosts } from "../connected-hosts/index.js";
|
||||||
|
const original = getGlobalDispatcher();
|
||||||
|
const servers: Server[] = [];
|
||||||
|
afterEach(async () => {
|
||||||
|
const installed = getGlobalDispatcher();
|
||||||
|
setGlobalDispatcher(original);
|
||||||
|
if (installed !== original) await installed.close();
|
||||||
|
await Promise.all(
|
||||||
|
servers.splice(0).map(
|
||||||
|
(server) =>
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
server.closeAllConnections();
|
||||||
|
server.close(() => resolve());
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
async function listen(server: Server) {
|
||||||
|
servers.push(server);
|
||||||
|
server.listen(0, "127.0.0.1");
|
||||||
|
await once(server, "listening");
|
||||||
|
const address = server.address();
|
||||||
|
if (!address || typeof address === "string") throw new Error("Missing address");
|
||||||
|
return `127.0.0.1:${address.port}`;
|
||||||
|
}
|
||||||
|
async function proxy() {
|
||||||
|
const tunnels: string[] = [];
|
||||||
|
const server = createServer();
|
||||||
|
server.on("connect", (request, socket) => {
|
||||||
|
tunnels.push(request.url!);
|
||||||
|
socket.end("HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n");
|
||||||
|
});
|
||||||
|
return { url: `http://${await listen(server)}`, tunnels };
|
||||||
|
}
|
||||||
|
test("loopback fetch and WebSockets bypass HTTP_PROXY with empty NO_PROXY", async () => {
|
||||||
|
const recorder = await proxy();
|
||||||
|
initializeEnvironmentProxy({ HTTP_PROXY: recorder.url, NO_PROXY: "" });
|
||||||
|
const server = createServer((_, response) => response.end("direct"));
|
||||||
|
const sockets = new WebSocketServer({ server });
|
||||||
|
sockets.on("connection", (socket) => socket.send("direct"));
|
||||||
|
const address = await listen(server);
|
||||||
|
expect(await (await fetch(`http://${address}`)).text()).toBe("direct");
|
||||||
|
const socket = new WebSocket(`ws://${address}`, {
|
||||||
|
agent: webSocketProxyAgent(`ws://${address}`),
|
||||||
|
});
|
||||||
|
expect((await once(socket, "message"))[0].toString()).toBe("direct");
|
||||||
|
socket.close();
|
||||||
|
await once(socket, "close");
|
||||||
|
await new Promise<void>((resolve) => sockets.close(() => resolve()));
|
||||||
|
for (const host of ["localhost", "127.0.0.1", "[::1]"])
|
||||||
|
expect(webSocketProxyAgent(`wss://${host}:1234`)).toBeUndefined();
|
||||||
|
expect(recorder.tunnels).toEqual([]);
|
||||||
|
});
|
||||||
|
test("fetch and WebSockets send non-loopback traffic through CONNECT", async () => {
|
||||||
|
const recorder = await proxy();
|
||||||
|
initializeEnvironmentProxy({ http_proxy: recorder.url });
|
||||||
|
await expect(
|
||||||
|
fetch("https://vendor.invalid/", { signal: AbortSignal.timeout(500) }),
|
||||||
|
).rejects.toThrow();
|
||||||
|
const socket = new WebSocket("wss://vendor.invalid", {
|
||||||
|
agent: webSocketProxyAgent("wss://vendor.invalid"),
|
||||||
|
});
|
||||||
|
await once(socket, "error");
|
||||||
|
expect(
|
||||||
|
recorder.tunnels.filter((host) => host === "vendor.invalid:443").length,
|
||||||
|
).toBeGreaterThanOrEqual(2);
|
||||||
|
});
|
||||||
|
test("NO_PROXY and absent variables keep matching WebSockets direct", async () => {
|
||||||
|
const recorder = await proxy();
|
||||||
|
initializeEnvironmentProxy({
|
||||||
|
HTTPS_PROXY: recorder.url,
|
||||||
|
NO_PROXY: "vendor.invalid,.example.invalid:443",
|
||||||
|
});
|
||||||
|
expect(webSocketProxyAgent("wss://vendor.invalid")).toBeUndefined();
|
||||||
|
await expect(
|
||||||
|
fetch("https://vendor.invalid/", { signal: AbortSignal.timeout(500) }),
|
||||||
|
).rejects.toThrow();
|
||||||
|
expect(webSocketProxyAgent("wss://sub.example.invalid")).toBeUndefined();
|
||||||
|
expect(webSocketProxyAgent("wss://example.invalid:444")).toBeDefined();
|
||||||
|
initializeEnvironmentProxy({ HTTP_PROXY: recorder.url, no_proxy: "*" });
|
||||||
|
expect(webSocketProxyAgent("ws://vendor.invalid")).toBeUndefined();
|
||||||
|
initializeEnvironmentProxy({});
|
||||||
|
expect(webSocketProxyAgent("wss://vendor.invalid")).toBeUndefined();
|
||||||
|
const address = await listen(createServer((_, response) => response.end("direct")));
|
||||||
|
expect(await (await fetch(`http://${address}`)).text()).toBe("direct");
|
||||||
|
expect(recorder.tunnels).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each(["127.0.0.1", "localhost"])(
|
||||||
|
"scheme-less %s proxies work for fetch and WebSockets",
|
||||||
|
async (host) => {
|
||||||
|
const recorder = await proxy();
|
||||||
|
const address = new URL(recorder.url);
|
||||||
|
initializeEnvironmentProxy({ https_proxy: `${host}:${address.port}` });
|
||||||
|
await expect(
|
||||||
|
fetch("https://vendor.invalid", { signal: AbortSignal.timeout(500) }),
|
||||||
|
).rejects.toThrow();
|
||||||
|
const socket = new WebSocket("wss://vendor.invalid", {
|
||||||
|
agent: webSocketProxyAgent("wss://vendor.invalid"),
|
||||||
|
});
|
||||||
|
await once(socket, "error");
|
||||||
|
expect(recorder.tunnels).toContain("vendor.invalid:443");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
"http://[",
|
||||||
|
"http://user%zz:secret@proxy.corp",
|
||||||
|
"socks5://127.0.0.1:7890",
|
||||||
|
"ftp://proxy.corp:8080",
|
||||||
|
])("ignores invalid or unsupported proxy %s without stopping startup", async (value) => {
|
||||||
|
const recorder = await proxy();
|
||||||
|
expect(initializeEnvironmentProxy({ HTTP_PROXY: recorder.url, https_proxy: value })).toEqual([
|
||||||
|
"https_proxy",
|
||||||
|
]);
|
||||||
|
expect(webSocketProxyAgent("wss://vendor.invalid")).toBeDefined();
|
||||||
|
await expect(
|
||||||
|
fetch("https://vendor.invalid", { signal: AbortSignal.timeout(500) }),
|
||||||
|
).rejects.toThrow();
|
||||||
|
const socket = new WebSocket("wss://vendor.invalid", {
|
||||||
|
agent: webSocketProxyAgent("wss://vendor.invalid"),
|
||||||
|
});
|
||||||
|
await once(socket, "error");
|
||||||
|
expect(recorder.tunnels).toEqual(["vendor.invalid:443", "vendor.invalid:443"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("daemon starts with an ignored proxy and writes one redacted warning to daemon.log", async () => {
|
||||||
|
const home = await mkdtemp(path.join(os.tmpdir(), "daemon-invalid-proxy-"));
|
||||||
|
const env = Object.fromEntries(
|
||||||
|
Object.entries(process.env).filter(
|
||||||
|
([key]) => !["http_proxy", "https_proxy"].includes(key.toLowerCase()),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const worker = fork(fileURLToPath(new URL("../daemon-worker.ts", import.meta.url)), [], {
|
||||||
|
execArgv: ["--import", "tsx"],
|
||||||
|
silent: true,
|
||||||
|
env: {
|
||||||
|
...env,
|
||||||
|
PASEO_HOME: home,
|
||||||
|
HOME: home,
|
||||||
|
USERPROFILE: home,
|
||||||
|
PASEO_LISTEN: "127.0.0.1:0",
|
||||||
|
PASEO_RELAY_ENABLED: "0",
|
||||||
|
https_proxy: "socks5://private:secret@127.0.0.1:7890",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
worker.stdout?.resume();
|
||||||
|
let stderr = "";
|
||||||
|
worker.stderr?.on("data", (chunk) => {
|
||||||
|
stderr += String(chunk);
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
worker.on("message", (message) => {
|
||||||
|
if (
|
||||||
|
typeof message === "object" &&
|
||||||
|
message !== null &&
|
||||||
|
"type" in message &&
|
||||||
|
message.type === "paseo:ready"
|
||||||
|
)
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
worker.on("exit", (code) => reject(new Error(`Worker exited ${code}: ${stderr}`)));
|
||||||
|
});
|
||||||
|
const log = await readFile(path.join(home, "daemon.log"), "utf8");
|
||||||
|
const warnings = log
|
||||||
|
.split("\n")
|
||||||
|
.filter((line) => line.includes("Ignoring invalid or unsupported HTTP proxy variable"));
|
||||||
|
expect(warnings).toHaveLength(1);
|
||||||
|
expect(JSON.parse(warnings[0]!).variable).toBe("https_proxy");
|
||||||
|
expect(warnings[0]).not.toContain("secret");
|
||||||
|
} finally {
|
||||||
|
if (worker.exitCode === null && worker.signalCode === null) {
|
||||||
|
const exited = once(worker, "exit");
|
||||||
|
worker.kill("SIGTERM");
|
||||||
|
await exited;
|
||||||
|
}
|
||||||
|
await rm(home, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
|
test("accepts a scheme-less corporate proxy hostname", () => {
|
||||||
|
expect(initializeEnvironmentProxy({ https_proxy: "proxy.corp:8080" })).toEqual([]);
|
||||||
|
expect(webSocketProxyAgent("wss://vendor.invalid")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an ignored lowercase variable falls back to the uppercase variable", async () => {
|
||||||
|
const recorder = await proxy();
|
||||||
|
expect(
|
||||||
|
initializeEnvironmentProxy({
|
||||||
|
https_proxy: "socks5://proxy.invalid",
|
||||||
|
HTTPS_PROXY: recorder.url,
|
||||||
|
}),
|
||||||
|
).toEqual(["https_proxy"]);
|
||||||
|
await expect(
|
||||||
|
fetch("https://vendor.invalid", { signal: AbortSignal.timeout(500) }),
|
||||||
|
).rejects.toThrow();
|
||||||
|
const socket = new WebSocket("wss://vendor.invalid", {
|
||||||
|
agent: webSocketProxyAgent("wss://vendor.invalid"),
|
||||||
|
});
|
||||||
|
await once(socket, "error");
|
||||||
|
expect(recorder.tunnels).toEqual(["vendor.invalid:443", "vendor.invalid:443"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("case-insensitive environment access reports a variable only once", () => {
|
||||||
|
const env = new Proxy(
|
||||||
|
{ https_proxy: "socks5://127.0.0.1:7890" },
|
||||||
|
{
|
||||||
|
get(target, key) {
|
||||||
|
return Reflect.get(target, typeof key === "string" ? key.toLowerCase() : key);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(initializeEnvironmentProxy(env)).toEqual(["https_proxy"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("connected-host links send remote WebSockets through the environment proxy", async () => {
|
||||||
|
const recorder = await proxy();
|
||||||
|
initializeEnvironmentProxy({ HTTPS_PROXY: recorder.url });
|
||||||
|
const home = await mkdtemp(path.join(os.tmpdir(), "connected-host-proxy-"));
|
||||||
|
const hosts = new ConnectedHosts(home, { serverId: "self", label: "Self" });
|
||||||
|
try {
|
||||||
|
await hosts.add(
|
||||||
|
{ connection: "wss://remote.invalid/ws" },
|
||||||
|
{ sourceId: "test", manageSources: true },
|
||||||
|
);
|
||||||
|
await expect.poll(() => recorder.tunnels).toContain("remote.invalid:443");
|
||||||
|
} finally {
|
||||||
|
await hosts.close();
|
||||||
|
await rm(home, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
import { MockLoadTestAgentClient } from "./agent/providers/mock-load-test-agent.js";
|
import { MockLoadTestAgentClient } from "./agent/providers/mock-load-test-agent.js";
|
||||||
import { createPaseoApi } from "@getpaseo/client";
|
import { createPaseoApi } from "@getpaseo/client";
|
||||||
import type { AgentManager } from "./agent/agent-manager.js";
|
|
||||||
import { execFileSync } from "node:child_process";
|
import { execFileSync } from "node:child_process";
|
||||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
import { mkdir, writeFile } from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { expect, test } from "vitest";
|
import { expect, test } from "vitest";
|
||||||
import type { z } from "zod";
|
import type { z } from "zod";
|
||||||
@@ -72,18 +71,15 @@ class SubscriptionPeer {
|
|||||||
async request(message: z.input<typeof SessionInboundMessageSchema>): Promise<void> {
|
async request(message: z.input<typeof SessionInboundMessageSchema>): Promise<void> {
|
||||||
this.socket.send(JSON.stringify({ type: "session", message }));
|
this.socket.send(JSON.stringify({ type: "session", message }));
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(() =>
|
||||||
() =>
|
this.frames.some(
|
||||||
this.frames.some(
|
(frame) =>
|
||||||
(frame) =>
|
frame.type === "session" &&
|
||||||
frame.type === "session" &&
|
"payload" in frame.message &&
|
||||||
"payload" in frame.message &&
|
"requestId" in frame.message.payload &&
|
||||||
"requestId" in frame.message.payload &&
|
"requestId" in message &&
|
||||||
"requestId" in message &&
|
frame.message.payload.requestId === message.requestId,
|
||||||
frame.message.payload.requestId === message.requestId,
|
),
|
||||||
),
|
|
||||||
// Diagnostics invoke external tools with their own multi-second deadlines.
|
|
||||||
{ timeout: 10_000 },
|
|
||||||
)
|
)
|
||||||
.toBe(true);
|
.toBe(true);
|
||||||
}
|
}
|
||||||
@@ -123,16 +119,6 @@ class SubscriptionPeer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function removedAgentIds(peer: SubscriptionPeer): string[] {
|
|
||||||
return peer.frames.flatMap((frame) =>
|
|
||||||
frame.type === "session" &&
|
|
||||||
frame.message.type === "agent_update" &&
|
|
||||||
frame.message.payload.kind === "remove"
|
|
||||||
? [frame.message.payload.agentId]
|
|
||||||
: [],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
test("a pure list reply belongs only to its source socket in a shared logical session", async () => {
|
test("a pure list reply belongs only to its source socket in a shared logical session", async () => {
|
||||||
const daemon = await createTestPaseoDaemon({ mcpEnabled: false });
|
const daemon = await createTestPaseoDaemon({ mcpEnabled: false });
|
||||||
const peers: SubscriptionPeer[] = [];
|
const peers: SubscriptionPeer[] = [];
|
||||||
@@ -239,96 +225,6 @@ test("combined filtered agent lists create independent server-owned observations
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test.each([
|
|
||||||
{
|
|
||||||
state: "live",
|
|
||||||
prepare: async (_manager: AgentManager, _agentId: string) => {},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
state: "stored-only",
|
|
||||||
prepare: (manager: AgentManager, agentId: string) => manager.closeAgent(agentId),
|
|
||||||
},
|
|
||||||
])(
|
|
||||||
"$state agent deletion reaches directory observers across logical sessions without broadcasting its reply",
|
|
||||||
async ({ prepare }) => {
|
|
||||||
const daemon = await createTestPaseoDaemon({ mcpEnabled: false });
|
|
||||||
const admin = new DaemonClient({
|
|
||||||
url: `ws://127.0.0.1:${daemon.port}/ws`,
|
|
||||||
appVersion: "0.8.0",
|
|
||||||
});
|
|
||||||
const peers: SubscriptionPeer[] = [];
|
|
||||||
try {
|
|
||||||
await admin.connect();
|
|
||||||
const workspace = (
|
|
||||||
await admin.createWorkspace({ source: { kind: "directory", path: daemon.staticDir } })
|
|
||||||
).workspace;
|
|
||||||
if (!workspace) throw new Error("Workspace creation failed");
|
|
||||||
const removed = await admin.createAgent({
|
|
||||||
provider: "codex",
|
|
||||||
cwd: daemon.staticDir,
|
|
||||||
workspaceId: workspace.id,
|
|
||||||
});
|
|
||||||
const survivor = await admin.createAgent({
|
|
||||||
provider: "codex",
|
|
||||||
cwd: daemon.staticDir,
|
|
||||||
workspaceId: workspace.id,
|
|
||||||
});
|
|
||||||
const sentinel = path.join(daemon.staticDir, "keep-project-file.txt");
|
|
||||||
await writeFile(sentinel, "keep this project file\n");
|
|
||||||
await prepare(daemon.daemon.agentManager, removed.id);
|
|
||||||
const requester = await SubscriptionPeer.connect(daemon.port, "deletion-origin");
|
|
||||||
const sibling = await SubscriptionPeer.connect(daemon.port, "deletion-origin");
|
|
||||||
const observer = await SubscriptionPeer.connect(daemon.port, "deletion-observer");
|
|
||||||
const legacy = await SubscriptionPeer.connect(daemon.port, "deletion-legacy", {
|
|
||||||
owned_subscriptions: false,
|
|
||||||
});
|
|
||||||
peers.push(requester, sibling, observer, legacy);
|
|
||||||
for (const [index, peer] of peers.entries()) {
|
|
||||||
await peer.request({
|
|
||||||
type: "fetch_agents_request",
|
|
||||||
requestId: `directory-${index}`,
|
|
||||||
subscribe: {},
|
|
||||||
});
|
|
||||||
const entries = peer.frames.flatMap((frame) =>
|
|
||||||
frame.type === "session" && frame.message.type === "fetch_agents_response"
|
|
||||||
? frame.message.payload.entries.map((entry) => entry.agent.id)
|
|
||||||
: [],
|
|
||||||
);
|
|
||||||
expect(entries.sort()).toEqual([removed.id, survivor.id].sort());
|
|
||||||
peer.frames.length = 0;
|
|
||||||
}
|
|
||||||
await requester.request({
|
|
||||||
type: "delete_agent_request",
|
|
||||||
requestId: "delete-observed-agent",
|
|
||||||
agentId: removed.id,
|
|
||||||
});
|
|
||||||
await expect
|
|
||||||
.poll(() => peers.map(removedAgentIds))
|
|
||||||
.toEqual([[removed.id], [removed.id], [removed.id], [removed.id]]);
|
|
||||||
const replies = peers.map((peer) =>
|
|
||||||
peer.frames.flatMap((frame) =>
|
|
||||||
frame.type === "session" && frame.message.type === "agent_deleted"
|
|
||||||
? [frame.message.payload]
|
|
||||||
: [],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
expect(replies).toEqual([
|
|
||||||
[{ agentId: removed.id, requestId: "delete-observed-agent" }],
|
|
||||||
[],
|
|
||||||
[],
|
|
||||||
[],
|
|
||||||
]);
|
|
||||||
expect(await daemon.daemon.agentStorage.get(removed.id)).toBeNull();
|
|
||||||
expect((await daemon.daemon.agentStorage.get(survivor.id))?.id).toBe(survivor.id);
|
|
||||||
expect(await readFile(sentinel, "utf8")).toBe("keep this project file\n");
|
|
||||||
} finally {
|
|
||||||
for (const peer of peers) peer.close();
|
|
||||||
await admin.close();
|
|
||||||
await daemon.close();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
test("workspace filters and identical queries coexist until their own release", async () => {
|
test("workspace filters and identical queries coexist until their own release", async () => {
|
||||||
const daemon = await createTestPaseoDaemon({ mcpEnabled: false });
|
const daemon = await createTestPaseoDaemon({ mcpEnabled: false });
|
||||||
const admin = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" });
|
const admin = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" });
|
||||||
@@ -783,8 +679,7 @@ test("same-workspace terminal directory observers do not share a teardown slot",
|
|||||||
peer.frames.length = 0;
|
peer.frames.length = 0;
|
||||||
const result = await admin.createTerminal(daemon.staticDir, "Observed terminal", undefined, {
|
const result = await admin.createTerminal(daemon.staticDir, "Observed terminal", undefined, {
|
||||||
workspaceId: workspace.id,
|
workspaceId: workspace.id,
|
||||||
command: process.execPath,
|
command: "/bin/cat",
|
||||||
args: ["-e", "process.stdin.on('data', data => process.stdout.write(data))"],
|
|
||||||
});
|
});
|
||||||
expect(result.error).toBe(null);
|
expect(result.error).toBe(null);
|
||||||
await expect
|
await expect
|
||||||
@@ -833,8 +728,7 @@ test("terminal output has separate server IDs and binary slots, including in a s
|
|||||||
).workspace!;
|
).workspace!;
|
||||||
const result = await admin.createTerminal(daemon.staticDir, "Output ownership", undefined, {
|
const result = await admin.createTerminal(daemon.staticDir, "Output ownership", undefined, {
|
||||||
workspaceId: workspace.id,
|
workspaceId: workspace.id,
|
||||||
command: process.execPath,
|
command: "/bin/cat",
|
||||||
args: ["-e", "process.stdin.on('data', data => process.stdout.write(data))"],
|
|
||||||
});
|
});
|
||||||
expect(result.error).toBe(null);
|
expect(result.error).toBe(null);
|
||||||
const idle = await SubscriptionPeer.connect(daemon.port, "terminal-output-session");
|
const idle = await SubscriptionPeer.connect(daemon.port, "terminal-output-session");
|
||||||
@@ -1529,7 +1423,7 @@ test("request outcomes for import and attention reach only their requesting sock
|
|||||||
});
|
});
|
||||||
const requester = await SubscriptionPeer.connect(daemon.port, "reply-shared");
|
const requester = await SubscriptionPeer.connect(daemon.port, "reply-shared");
|
||||||
const sibling = await SubscriptionPeer.connect(daemon.port, "reply-shared");
|
const sibling = await SubscriptionPeer.connect(daemon.port, "reply-shared");
|
||||||
const admin = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" });
|
const admin = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||||
try {
|
try {
|
||||||
await admin.connect();
|
await admin.connect();
|
||||||
await requester.request({
|
await requester.request({
|
||||||
@@ -1568,7 +1462,7 @@ test("request outcomes for import and attention reach only their requesting sock
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const agent = await admin.createAgent({
|
const agent = await admin.createAgent({
|
||||||
provider: "mock",
|
provider: "codex",
|
||||||
cwd: daemon.staticDir,
|
cwd: daemon.staticDir,
|
||||||
title: "Attention",
|
title: "Attention",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
import { mkdtemp, rm } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { expect, test } from "vitest";
|
import { expect, test } from "vitest";
|
||||||
import { DaemonClient } from "../test-utils/daemon-client.js";
|
import { DaemonClient } from "../test-utils/daemon-client.js";
|
||||||
import type { AgentClient, AgentSessionConfig } from "../agent/agent-sdk-types.js";
|
import type { AgentSessionConfig } from "../agent/agent-sdk-types.js";
|
||||||
import { createTestAgentClient } from "../test-utils/fake-agent-client.js";
|
import { createTestAgentClient } from "../test-utils/fake-agent-client.js";
|
||||||
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||||
|
|
||||||
@@ -72,71 +72,3 @@ test("the configuration example saves Codex options and injects launch-only MCP
|
|||||||
await rm(directory, { recursive: true, force: true });
|
await rm(directory, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
}, 60_000);
|
}, 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);
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { createPluginClientId } from "./plugin-session-identity.js";
|
|||||||
|
|
||||||
import { PluginSettingsStore } from "./settings/index.js";
|
import { PluginSettingsStore } from "./settings/index.js";
|
||||||
import { readPluginProviderIcon } from "./provider-icon.js";
|
import { readPluginProviderIcon } from "./provider-icon.js";
|
||||||
|
import { initializeEnvironmentProxy } from "../network/index.js";
|
||||||
function describeError(error: unknown): string {
|
function describeError(error: unknown): string {
|
||||||
return error instanceof Error ? error.message : String(error);
|
return error instanceof Error ? error.message : String(error);
|
||||||
}
|
}
|
||||||
@@ -606,6 +607,7 @@ if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|||||||
const parsed = PluginProcessRequestSchema.safeParse(raw);
|
const parsed = PluginProcessRequestSchema.safeParse(raw);
|
||||||
if (!worker && parsed.success && parsed.data.type === "initialize") {
|
if (!worker && parsed.success && parsed.data.type === "initialize") {
|
||||||
try {
|
try {
|
||||||
|
initializeEnvironmentProxy();
|
||||||
worker = createPluginWorker({ channel, contribute: evaluateBundle(parsed.data.bundle) });
|
worker = createPluginWorker({ channel, contribute: evaluateBundle(parsed.data.bundle) });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
channel.send({ type: "fatal", error: describeError(error) });
|
channel.send({ type: "fatal", error: describeError(error) });
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import { initializeEnvironmentProxy } from "../../network/index.js";
|
||||||
|
import { DaemonClient } from "../../test-utils/daemon-client.js";
|
||||||
|
import { createTestPaseoDaemon } from "../../test-utils/paseo-daemon.js";
|
||||||
|
import { BuiltinPluginLoader, resolveBuiltinPluginsRoot } from "../builtin/index.js";
|
||||||
|
|
||||||
|
const mode = process.argv[2];
|
||||||
|
if (mode !== "builtin" && mode !== "directory") throw new Error("Expected builtin or directory");
|
||||||
|
const sources = ["claude-usage-source", "codex-usage-source"];
|
||||||
|
// Built-ins run inside the daemon; directory plugins initialize their own subprocess network.
|
||||||
|
if (mode === "builtin") initializeEnvironmentProxy();
|
||||||
|
const daemon = await createTestPaseoDaemon({
|
||||||
|
pluginsEnabled: mode === "directory",
|
||||||
|
builtinPlugins: new BuiltinPluginLoader(
|
||||||
|
resolveBuiltinPluginsRoot(),
|
||||||
|
mode === "builtin" ? sources : [],
|
||||||
|
),
|
||||||
|
});
|
||||||
|
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||||
|
try {
|
||||||
|
await client.connect();
|
||||||
|
if (mode === "directory") {
|
||||||
|
for (const source of sources) {
|
||||||
|
await client.installDirectoryPlugin(path.join(resolveBuiltinPluginsRoot(), source), source);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await client.listUsageReports();
|
||||||
|
} finally {
|
||||||
|
await client.close();
|
||||||
|
await daemon.close();
|
||||||
|
}
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
|
import { execFile } from "node:child_process";
|
||||||
|
import { promisify } from "node:util";
|
||||||
import { cp, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
import { cp, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||||
|
import { createServer } from "node:http";
|
||||||
|
import { once } from "node:events";
|
||||||
import { expect, test } from "vitest";
|
import { expect, test } from "vitest";
|
||||||
import { DaemonClient } from "../test-utils/daemon-client.js";
|
import { DaemonClient } from "../test-utils/daemon-client.js";
|
||||||
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||||
@@ -335,3 +339,74 @@ export default function contribute(server) {
|
|||||||
await rm(root, { recursive: true, force: true });
|
await rm(root, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
}, 60_000);
|
}, 60_000);
|
||||||
|
|
||||||
|
test.each(["builtin", "directory"])(
|
||||||
|
"%s usage plugins reach the environment proxy",
|
||||||
|
async (mode) => {
|
||||||
|
const root = await mkdtemp(path.join(os.tmpdir(), "usage-proxy-"));
|
||||||
|
const tunnels: string[] = [];
|
||||||
|
const proxy = createServer();
|
||||||
|
proxy.on("connect", (request, socket) => {
|
||||||
|
tunnels.push(request.url!);
|
||||||
|
socket.end("HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n");
|
||||||
|
});
|
||||||
|
proxy.listen(0, "127.0.0.1");
|
||||||
|
await once(proxy, "listening");
|
||||||
|
const address = proxy.address();
|
||||||
|
if (!address || typeof address === "string") throw new Error("Missing proxy address");
|
||||||
|
const guard = path.join(root, "network-guard.cjs");
|
||||||
|
// Prevent vendor DNS/network access even when proxy support is broken.
|
||||||
|
await writeFile(
|
||||||
|
guard,
|
||||||
|
`const dns = require('node:dns');
|
||||||
|
const lookup = dns.lookup;
|
||||||
|
dns.lookup = function(host, ...args) {
|
||||||
|
if (host === 'localhost' || host === '127.0.0.1' || host === '::1') return lookup.call(this, host, ...args);
|
||||||
|
args.at(-1)(Object.assign(new Error('External DNS forbidden in proxy test'), { code: 'ENOTFOUND' }));
|
||||||
|
};`,
|
||||||
|
);
|
||||||
|
await mkdir(path.join(root, ".claude"));
|
||||||
|
await mkdir(path.join(root, ".codex"));
|
||||||
|
await writeFile(
|
||||||
|
path.join(root, ".claude", ".credentials.json"),
|
||||||
|
JSON.stringify({ claudeAiOauth: { accessToken: "fixture-claude" } }),
|
||||||
|
);
|
||||||
|
await writeFile(
|
||||||
|
path.join(root, ".codex", "auth.json"),
|
||||||
|
JSON.stringify({ tokens: { access_token: "fixture-codex", account_id: "fixture-account" } }),
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await promisify(execFile)(
|
||||||
|
process.execPath,
|
||||||
|
[
|
||||||
|
"--import",
|
||||||
|
"tsx",
|
||||||
|
fileURLToPath(new URL("./test-fixtures/usage-proxy-daemon.ts", import.meta.url)),
|
||||||
|
mode,
|
||||||
|
],
|
||||||
|
{
|
||||||
|
timeout: 45_000,
|
||||||
|
maxBuffer: 1024 * 1024,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
HOME: root,
|
||||||
|
USERPROFILE: root,
|
||||||
|
CODEX_HOME: path.join(root, ".codex"),
|
||||||
|
CLAUDE_CONFIG_DIR: path.join(root, ".claude"),
|
||||||
|
NODE_OPTIONS: `--require=${JSON.stringify(guard.replaceAll("\\", "/"))}`,
|
||||||
|
https_proxy: `http://127.0.0.1:${address.port}`,
|
||||||
|
no_proxy: "",
|
||||||
|
NO_PROXY: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(tunnels).toContain("api.anthropic.com:443");
|
||||||
|
expect(tunnels).toContain("chatgpt.com:443");
|
||||||
|
} finally {
|
||||||
|
proxy.closeAllConnections();
|
||||||
|
await new Promise<void>((resolve) => proxy.close(() => resolve()));
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
60_000,
|
||||||
|
);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { WebSocket } from "ws";
|
import { WebSocket } from "ws";
|
||||||
import type pino from "pino";
|
import type pino from "pino";
|
||||||
import { buildRelayWebSocketUrl } from "@getpaseo/protocol/daemon-endpoints";
|
import { buildRelayWebSocketUrl } from "@getpaseo/protocol/daemon-endpoints";
|
||||||
|
import { webSocketProxyAgent } from "./network/index.js";
|
||||||
import type { ExternalSocketMetadata } from "./websocket-server.js";
|
import type { ExternalSocketMetadata } from "./websocket-server.js";
|
||||||
|
|
||||||
export interface RelayTransportOptions {
|
export interface RelayTransportOptions {
|
||||||
@@ -56,7 +57,7 @@ const RELAY_WEBSOCKET_OPTIONS = {
|
|||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
function createDefaultRelayWebSocket(url: string): RelayWebSocketLike {
|
function createDefaultRelayWebSocket(url: string): RelayWebSocketLike {
|
||||||
return new WebSocket(url, RELAY_WEBSOCKET_OPTIONS);
|
return new WebSocket(url, { ...RELAY_WEBSOCKET_OPTIONS, agent: webSocketProxyAgent(url) });
|
||||||
}
|
}
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
|||||||
@@ -1992,24 +1992,6 @@ export class Session {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.type === "agent_removed") {
|
|
||||||
if (event.internal) return;
|
|
||||||
void this.agentUpdates
|
|
||||||
.removeAgent(event.agentId)
|
|
||||||
.then(() => {
|
|
||||||
if (event.workspaceId)
|
|
||||||
return this.emitWorkspaceUpdateForWorkspaceId(event.workspaceId);
|
|
||||||
return undefined;
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
this.sessionLogger.error(
|
|
||||||
{ err: error, agentId: event.agentId },
|
|
||||||
"Failed to emit agent removal",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.type === "agent_state") {
|
if (event.type === "agent_state") {
|
||||||
this.sessionLogger.trace(
|
this.sessionLogger.trace(
|
||||||
{
|
{
|
||||||
@@ -3355,8 +3337,10 @@ export class Session {
|
|||||||
private async handleDeleteAgentRequest(agentId: string, requestId: string): Promise<void> {
|
private async handleDeleteAgentRequest(agentId: string, requestId: string): Promise<void> {
|
||||||
this.sessionLogger.info({ agentId }, `Deleting agent ${agentId} from registry`);
|
this.sessionLogger.info({ agentId }, `Deleting agent ${agentId} from registry`);
|
||||||
|
|
||||||
const knownAgent =
|
const knownWorkspaceId =
|
||||||
this.agentManager.getAgent(agentId) ?? (await this.agentStorage.get(agentId));
|
this.agentManager.getAgent(agentId)?.workspaceId ??
|
||||||
|
(await this.agentStorage.get(agentId))?.workspaceId ??
|
||||||
|
null;
|
||||||
|
|
||||||
// File-backed storage still needs an early delete fence before closeAgent().
|
// File-backed storage still needs an early delete fence before closeAgent().
|
||||||
beginAgentDeleteIfSupported(this.agentStorage, agentId);
|
beginAgentDeleteIfSupported(this.agentStorage, agentId);
|
||||||
@@ -3377,8 +3361,6 @@ export class Session {
|
|||||||
try {
|
try {
|
||||||
await this.agentStorage.remove(agentId);
|
await this.agentStorage.remove(agentId);
|
||||||
await this.agentManager.deleteAgentState(agentId);
|
await this.agentManager.deleteAgentState(agentId);
|
||||||
// Directory removal is daemon-wide; agent_deleted below stays a source-scoped RPC reply.
|
|
||||||
if (knownAgent) this.agentManager.notifyAgentRemoved(knownAgent);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.sessionLogger.error({ err: error, agentId }, `Failed to fully delete agent ${agentId}`);
|
this.sessionLogger.error({ err: error, agentId }, `Failed to fully delete agent ${agentId}`);
|
||||||
}
|
}
|
||||||
@@ -3390,6 +3372,12 @@ export class Session {
|
|||||||
requestId,
|
requestId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await this.agentUpdates.removeAgent(agentId);
|
||||||
|
|
||||||
|
if (knownWorkspaceId) {
|
||||||
|
await this.emitWorkspaceUpdateForWorkspaceId(knownWorkspaceId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleArchiveAgentRequest(agentId: string, requestId: string): Promise<void> {
|
private async handleArchiveAgentRequest(agentId: string, requestId: string): Promise<void> {
|
||||||
|
|||||||
@@ -5,8 +5,7 @@ import { fileURLToPath } from "node:url";
|
|||||||
import { writeFile, truncate, rm } from "node:fs/promises";
|
import { writeFile, truncate, rm } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
import { build } from "esbuild";
|
|
||||||
import { isPlatform } from "../test-utils/platform.js";
|
import { isPlatform } from "../test-utils/platform.js";
|
||||||
import {
|
import {
|
||||||
readPaseoConfig,
|
readPaseoConfig,
|
||||||
@@ -21,40 +20,21 @@ import {
|
|||||||
writePaseoConfigForEdit,
|
writePaseoConfigForEdit,
|
||||||
} from "./paseo-config-file.js";
|
} from "./paseo-config-file.js";
|
||||||
|
|
||||||
let probeDirectory: string;
|
|
||||||
let probePath: string;
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
probeDirectory = mkdtempSync(join(tmpdir(), "paseo-config-probe-"));
|
|
||||||
probePath = join(probeDirectory, "config-responsiveness.mjs");
|
|
||||||
// Compile before starting the measured process. A runtime TypeScript loader
|
|
||||||
// owns MessagePorts that the responsiveness probe otherwise charges as work.
|
|
||||||
await build({
|
|
||||||
entryPoints: [
|
|
||||||
fileURLToPath(new URL("./test-fixtures/config-responsiveness.ts", import.meta.url)),
|
|
||||||
],
|
|
||||||
outfile: probePath,
|
|
||||||
bundle: true,
|
|
||||||
platform: "node",
|
|
||||||
format: "esm",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(() => {
|
|
||||||
rmSync(probeDirectory, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
async function measureConfigRead(directory: string) {
|
async function measureConfigRead(directory: string) {
|
||||||
const { stdout, stderr } = await promisify(execFile)(process.execPath, [probePath, directory], {
|
const fixture = fileURLToPath(
|
||||||
timeout: 15_000,
|
new URL("./test-fixtures/config-responsiveness.ts", import.meta.url),
|
||||||
});
|
);
|
||||||
|
const { stdout, stderr } = await promisify(execFile)(
|
||||||
|
process.execPath,
|
||||||
|
["--import", "tsx", fixture, directory],
|
||||||
|
{ timeout: 15_000 },
|
||||||
|
);
|
||||||
process.stderr.write(stderr);
|
process.stderr.write(stderr);
|
||||||
return JSON.parse(stdout) as {
|
return JSON.parse(stdout) as {
|
||||||
result: Awaited<ReturnType<typeof readPaseoConfigForEdit>>;
|
result: Awaited<ReturnType<typeof readPaseoConfigForEdit>>;
|
||||||
blockingMs: number;
|
blockingMs: number;
|
||||||
filesystemRequests: number;
|
filesystemRequests: number;
|
||||||
yields: number;
|
yields: number;
|
||||||
workerMessages: number;
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,7 +84,7 @@ describe("paseo config file substrate", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("excludes test-runner and loader activity from the config read budget", async () => {
|
it("excludes unrelated test-runner CPU from the config read budget", async () => {
|
||||||
await writeFile(join(tempDir, "paseo.json"), JSON.stringify({ worktree: { setup: "npm ci" } }));
|
await writeFile(join(tempDir, "paseo.json"), JSON.stringify({ worktree: { setup: "npm ci" } }));
|
||||||
const unrelatedWork = new Promise<void>((resolve) => {
|
const unrelatedWork = new Promise<void>((resolve) => {
|
||||||
setImmediate(() => {
|
setImmediate(() => {
|
||||||
@@ -120,10 +100,8 @@ describe("paseo config file substrate", () => {
|
|||||||
resolve();
|
resolve();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
const { result, blockingMs, filesystemRequests, workerMessages } =
|
const { result, blockingMs, filesystemRequests } = await measureConfigRead(tempDir);
|
||||||
await measureConfigRead(tempDir);
|
|
||||||
await unrelatedWork;
|
await unrelatedWork;
|
||||||
expect(workerMessages).toBe(0);
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
ok: true,
|
ok: true,
|
||||||
config: { worktree: { setup: "npm ci" } },
|
config: { worktree: { setup: "npm ci" } },
|
||||||
@@ -141,13 +119,11 @@ describe("paseo config file substrate", () => {
|
|||||||
worktree: { setup: "npm ci" },
|
worktree: { setup: "npm ci" },
|
||||||
};
|
};
|
||||||
await writeFile(join(tempDir, "paseo.json"), JSON.stringify(config));
|
await writeFile(join(tempDir, "paseo.json"), JSON.stringify(config));
|
||||||
const { result, blockingMs, filesystemRequests, yields, workerMessages } =
|
const { result, blockingMs, filesystemRequests, yields } = await measureConfigRead(tempDir);
|
||||||
await measureConfigRead(tempDir);
|
|
||||||
expect(result).toEqual({ ok: true, config, revision: await statPaseoConfigPath(tempDir) });
|
expect(result).toEqual({ ok: true, config, revision: await statPaseoConfigPath(tempDir) });
|
||||||
expect(blockingMs).toBeLessThan(20);
|
expect(blockingMs).toBeLessThan(20);
|
||||||
expect(filesystemRequests).toBeGreaterThan(0);
|
expect(filesystemRequests).toBeGreaterThan(0);
|
||||||
expect(yields).toBeGreaterThan(0);
|
expect(yields).toBeGreaterThan(0);
|
||||||
expect(workerMessages).toBe(0);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects a 32MiB config with main-thread work below 20ms", async () => {
|
it("rejects a 32MiB config with main-thread work below 20ms", async () => {
|
||||||
@@ -155,11 +131,9 @@ describe("paseo config file substrate", () => {
|
|||||||
join(tempDir, "paseo.json"),
|
join(tempDir, "paseo.json"),
|
||||||
JSON.stringify({ extra: "x".repeat(32 * 1024 * 1024) }),
|
JSON.stringify({ extra: "x".repeat(32 * 1024 * 1024) }),
|
||||||
);
|
);
|
||||||
const { result, blockingMs, filesystemRequests, workerMessages } =
|
const { result, blockingMs, filesystemRequests } = await measureConfigRead(tempDir);
|
||||||
await measureConfigRead(tempDir);
|
|
||||||
expect(blockingMs).toBeLessThan(20);
|
expect(blockingMs).toBeLessThan(20);
|
||||||
expect(filesystemRequests).toBeGreaterThan(0);
|
expect(filesystemRequests).toBeGreaterThan(0);
|
||||||
expect(workerMessages).toBe(0);
|
|
||||||
expect(result).toEqual({ ok: false, error: { code: "invalid_project_config" } });
|
expect(result).toEqual({ ok: false, error: { code: "invalid_project_config" } });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,24 +1,14 @@
|
|||||||
import { createHook, executionAsyncResource } from "node:async_hooks";
|
|
||||||
import { MessagePort } from "node:worker_threads";
|
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { setImmediate as nextTurn } from "node:timers/promises";
|
import { setImmediate as nextTurn } from "node:timers/promises";
|
||||||
import { measureMainThreadWork } from "../../test-utils/main-thread-work.js";
|
import { measureMainThreadWork } from "../../test-utils/main-thread-work.js";
|
||||||
import { readPaseoConfigForEdit } from "../paseo-config-file.js";
|
import { readPaseoConfigForEdit } from "../paseo-config-file.js";
|
||||||
|
|
||||||
// The parent bundles this fixture before launching plain Node, keeping test
|
// Keep Vitest transforms, reporting, and unrelated callbacks outside the measured
|
||||||
// runner callbacks and runtime loader messages outside the measured thread.
|
// thread. Read the real file once, without warming the config snapshot or schema.
|
||||||
// Read the real file once, without warming the config snapshot or schema.
|
|
||||||
const directory = process.argv[2];
|
const directory = process.argv[2];
|
||||||
assert.ok(directory, "Config directory is required");
|
assert.ok(directory, "Config directory is required");
|
||||||
await nextTurn();
|
await nextTurn();
|
||||||
let workerMessages = 0;
|
|
||||||
const activity = createHook({
|
|
||||||
before() {
|
|
||||||
if (executionAsyncResource() instanceof MessagePort) workerMessages++;
|
|
||||||
},
|
|
||||||
}).enable();
|
|
||||||
const measured = await measureMainThreadWork("project config main-thread work", () =>
|
const measured = await measureMainThreadWork("project config main-thread work", () =>
|
||||||
readPaseoConfigForEdit(directory),
|
readPaseoConfigForEdit(directory),
|
||||||
);
|
);
|
||||||
activity.disable();
|
process.stdout.write(JSON.stringify(measured));
|
||||||
process.stdout.write(JSON.stringify({ ...measured, workerMessages }));
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/website",
|
"name": "@getpaseo/website",
|
||||||
"version": "0.14.0",
|
"version": "0.14.0-beta.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -486,74 +486,3 @@ diff --git a/node_modules/react-native-unistyles/lib/commonjs/core/withUnistyles
|
|||||||
...(narrowedProps.style ? {
|
...(narrowedProps.style ? {
|
||||||
style: styleSecrets.styles
|
style: styleSecrets.styles
|
||||||
} : {}),
|
} : {}),
|
||||||
diff --git a/node_modules/react-native-unistyles/lib/commonjs/web/runtime.js b/node_modules/react-native-unistyles/lib/commonjs/web/runtime.js
|
|
||||||
index 305fa49..5957aa5 100644
|
|
||||||
--- a/node_modules/react-native-unistyles/lib/commonjs/web/runtime.js
|
|
||||||
+++ b/node_modules/react-native-unistyles/lib/commonjs/web/runtime.js
|
|
||||||
@@ -35,10 +35,17 @@ class UnistylesRuntime {
|
|
||||||
return this.darkMedia;
|
|
||||||
}
|
|
||||||
get colorScheme() {
|
|
||||||
+ // Read the scheme through fresh lists, never through lightMedia/darkMedia. Chromium defers
|
|
||||||
+ // MediaQueryList change events while the document is hidden and drops the pending event
|
|
||||||
+ // of any list whose `matches` was read before the document became visible again, which
|
|
||||||
+ // would silence the adaptive-theme listeners registered on those two lists.
|
|
||||||
+ if ((0, _utils.isServer)()) {
|
|
||||||
+ return _types.ColorScheme.Unspecified;
|
|
||||||
+ }
|
|
||||||
switch (true) {
|
|
||||||
- case this.getLightMedia()?.matches:
|
|
||||||
+ case window.matchMedia('(prefers-color-scheme: light)').matches:
|
|
||||||
return _types.ColorScheme.Light;
|
|
||||||
- case this.getDarkMedia()?.matches:
|
|
||||||
+ case window.matchMedia('(prefers-color-scheme: dark)').matches:
|
|
||||||
return _types.ColorScheme.Dark;
|
|
||||||
default:
|
|
||||||
return _types.ColorScheme.Unspecified;
|
|
||||||
diff --git a/node_modules/react-native-unistyles/lib/module/web/runtime.js b/node_modules/react-native-unistyles/lib/module/web/runtime.js
|
|
||||||
index 7f13a06..c487bee 100644
|
|
||||||
--- a/node_modules/react-native-unistyles/lib/module/web/runtime.js
|
|
||||||
+++ b/node_modules/react-native-unistyles/lib/module/web/runtime.js
|
|
||||||
@@ -31,10 +31,17 @@ export class UnistylesRuntime {
|
|
||||||
return this.darkMedia;
|
|
||||||
}
|
|
||||||
get colorScheme() {
|
|
||||||
+ // Read the scheme through fresh lists, never through lightMedia/darkMedia. Chromium defers
|
|
||||||
+ // MediaQueryList change events while the document is hidden and drops the pending event
|
|
||||||
+ // of any list whose `matches` was read before the document became visible again, which
|
|
||||||
+ // would silence the adaptive-theme listeners registered on those two lists.
|
|
||||||
+ if (isServer()) {
|
|
||||||
+ return ColorScheme.Unspecified;
|
|
||||||
+ }
|
|
||||||
switch (true) {
|
|
||||||
- case this.getLightMedia()?.matches:
|
|
||||||
+ case window.matchMedia('(prefers-color-scheme: light)').matches:
|
|
||||||
return ColorScheme.Light;
|
|
||||||
- case this.getDarkMedia()?.matches:
|
|
||||||
+ case window.matchMedia('(prefers-color-scheme: dark)').matches:
|
|
||||||
return ColorScheme.Dark;
|
|
||||||
default:
|
|
||||||
return ColorScheme.Unspecified;
|
|
||||||
diff --git a/node_modules/react-native-unistyles/src/web/runtime.ts b/node_modules/react-native-unistyles/src/web/runtime.ts
|
|
||||||
index a16dac0..6519858 100644
|
|
||||||
--- a/node_modules/react-native-unistyles/src/web/runtime.ts
|
|
||||||
+++ b/node_modules/react-native-unistyles/src/web/runtime.ts
|
|
||||||
@@ -41,8 +41,16 @@ export class UnistylesRuntime {
|
|
||||||
get colorScheme() {
|
|
||||||
+ // Read the scheme through fresh lists, never through lightMedia/darkMedia. Chromium defers
|
|
||||||
+ // MediaQueryList change events while the document is hidden and drops the pending event
|
|
||||||
+ // of any list whose `matches` was read before the document became visible again, which
|
|
||||||
+ // would silence the adaptive-theme listeners registered on those two lists.
|
|
||||||
+ if (isServer()) {
|
|
||||||
+ return ColorScheme.Unspecified
|
|
||||||
+ }
|
|
||||||
+
|
|
||||||
switch (true) {
|
|
||||||
- case this.getLightMedia()?.matches:
|
|
||||||
+ case window.matchMedia('(prefers-color-scheme: light)').matches:
|
|
||||||
return ColorScheme.Light
|
|
||||||
- case this.getDarkMedia()?.matches:
|
|
||||||
+ case window.matchMedia('(prefers-color-scheme: dark)').matches:
|
|
||||||
return ColorScheme.Dark
|
|
||||||
default:
|
|
||||||
return ColorScheme.Unspecified
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/builtin-plugins",
|
"name": "@getpaseo/builtin-plugins",
|
||||||
"version": "0.14.0",
|
"version": "0.14.0-beta.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ const patchedPackages = [
|
|||||||
},
|
},
|
||||||
// Registry cleanup: remove after react-native-unistyles ships
|
// Registry cleanup: remove after react-native-unistyles ships
|
||||||
// https://github.com/jpudysz/react-native-unistyles/pull/1203.
|
// https://github.com/jpudysz/react-native-unistyles/pull/1203.
|
||||||
// Adaptive themes: keep until colorScheme reads no longer consume listener-owned media changes.
|
|
||||||
// withUnistyles: also keep the uniProps filter until upstream consumes its control prop.
|
// withUnistyles: also keep the uniProps filter until upstream consumes its control prop.
|
||||||
{
|
{
|
||||||
nodeModulesPath: "node_modules/react-native-unistyles",
|
nodeModulesPath: "node_modules/react-native-unistyles",
|
||||||
|
|||||||
Reference in New Issue
Block a user