Compare commits

...
Author SHA1 Message Date
paseo-bot[bot]andluke 4f96142112 test(app): read expanded turns across the virtual window
Verify every command remains accessible while scrolling and after older history loads. Requiring all expanded rows in the DOM at once contradicts bounded desktop mounting.

Co-authored-by: luke <luopeihuan@gmail.com>
2026-11-03 10:04:10 +00:00
luke a5fa06b80b fix(app): virtualize chat turns that exceed the mounted window
- Desktop web aligns the mounted tail to the current turn, so one long tool run stayed fully mounted and stalled the chat open.
- Cap that rewind at 60 rows for the web virtualizer. A larger minimum still wins, and history paging plus the replica cache still keep a whole turn.
2026-11-03 16:16:32 +08:00
paseo-bot[bot]andHomicideZero b507fc1986 fix(server): return 404 for missing well-known metadata (#8622)
* fix(server): return 404 for unserved well-known paths

The daemon web UI middleware fell back to the SPA's index.html for every
unknown GET, including /.well-known/* URIs. MCP clients that run OAuth
discovery (RFC 9728 protected resource metadata, RFC 8414 authorization
server metadata) received a 200 text/html body, treated it as malformed
metadata and retried instead of concluding the server has no OAuth.

Well-known paths now serve a real file from the dist directory if one
exists and otherwise return a JSON 404. Other paths keep the SPA fallback.

* fix(server): verify metadata routing through the daemon

Retain the contributed well-known routing fix with named resolver options and a full daemon regression that fails when metadata requests receive app HTML.

Co-authored-by: HomicideZero <219062565+HomicideZero@users.noreply.github.com>

---------

Co-authored-by: HomicideZero <219062565+HomicideZero@users.noreply.github.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-03 07:53:13 +00:00
Mohamed Boudra e546497a21 fix(workspaces): keep merged pull requests visible
* fix(workspaces): keep merged pull requests visible

A remote update before merge can leave the checkout behind the PR head. Match terminal requests by branch for display and require an exact fresh head match before automatic archive.

* fix(forges): retain pull requests targeting a fork

A fork checkout can have its own pull request as well as one targeting its parent. Compare both candidates before choosing which request to display.

* fix(forges): rank closed drafts as terminal

GitHub retains the draft flag after closing a pull request. Selection must use the request state so a closed draft does not override an open or exact-head request.
2026-11-03 13:43:02 +07:00
Tahir Khan b807c261ca Show progress while plugin screens load (#8123)
Plugin screens can report unavailable while a successful catalog request
is still pending. Track the host catalog state so these screens show
progress until their contributions register.

Keep missing surfaces, failed requests, and offline hosts distinct from
loading. Retain existing installed surfaces during catalog refreshes.

Cover pending-to-ready rendering with a real browser regression and
catalog lifecycle tests.

Related to getpaseo/paseo#7088
2026-11-03 06:05:00 +00:00
Mohamed Boudra 85be2121b8 Link #123 issue and pull request references in chat (#8636)
* feat(app): link #123 issue and pull request references in Markdown

Agents cite issues and pull requests by number; reading one meant looking
up the URL by hand. Markdown in a workspace now links forge shorthand
against the workspace's origin remote: `#N`, GitLab and Gitea `!N`, and
cross-repository `owner/repo#N` on the same host.

Each forge's URL grammar declares which sigil maps to which path, so the
syntax stays per forge: GitHub has no `!`, and its `#` targets /issues/,
which GitHub redirects to the pull request. Recognition goes through the
Markdown engine's text link regex so code spans, fences and authored links
stay plain without a second Markdown parse while streaming. The reference
alternative sits before path recognition so `group/project#12` is not read
as a file path.

* fix(app): keep http:// for self-hosted forge web links

A self-hosted forge cloned over http:// serves its web UI over http on
the same origin. Forcing https made reference, blob and tree links fail
against an HTTP-only port. Cloud hosts and SSH remotes stay on https.
2026-11-03 13:00:24 +07:00
Mohamed Boudra f4eeb8a538 chore(release): reconcile main with v0.15.0 (#8635)
Bring in what v0.15.0 shipped: the 0.15.0 entry in CHANGELOG.md and
CHANGELOG.zh-CN.md exactly as tagged (replacing the 0.15.0-beta.1 series
entry), version 0.15.0 across package.json/package-lock.json, the F-Droid
changelogs 150001-150004.txt, and the matching Nix npm deps hash.
2026-11-03 12:44:50 +07:00
Mohamed Boudra e1b20384a1 fix(pi): restore complete chat history after compaction (#8523)
* fix(pi): restore complete active-branch chat history

Replay the saved transcript ancestry instead of the compacted model context so older messages retain their native rewind identities and Fork chat boundaries.

* refactor(pi): name runtime options and assert restored tool output

* fix(pi): accept native system messages with text blocks
2026-11-03 12:20:34 +07:00
Mohamed Boudra fc710be1af fix(app): keep linked badges inline in rendered Markdown on web (#8530)
The library's web renderer marked an image inline only when it was a
direct child of a paragraph with other content. A linked image sits
under a Link node, so badges such as a review bot's
`[![Retrigger](badge.svg)](url) Confidence Score` header rendered as
100%-wide, 320px-tall block images in the PR panel and file preview.

Images nested in links (or emphasis) now count as inline when their
paragraph has other content; a paragraph made only of images or
image-only links still renders them as blocks. Inline images now take
the line height with automatic width instead of a line-height square,
so wide badges and photos keep their aspect ratio instead of squashing.
2026-11-03 11:46:15 +07:00
paseo-bot[bot]andOkan YILDIRIM 66e1523f2f Keep Codex multi-file patch diffs in the timeline (#8613)
* fix(codex): retain multi-file patch diffs in timeline details

Combine each file's diff in canonical file-change items and legacy patch
text, keeping the legacy parsers and envelope unchanged. Cover both legacy
lifecycle events and the failed status.

* fix(codex): handle path-only and moved files in patch diffs

Multi-file assembly generated headers without patch text and discarded
move destinations when reducing object kind tags. Skip textless entries
and retain move_path for generated destination headers, keeping the
single-file mapping and existing truncation limit.

Add mapper regressions for both cases, demonstrated failing on 179c3f7f6
before the fix.

* fix(codex): keep later files in bounded patch previews

Share the existing diff detail budget across files so a large first diff cannot hide later changes in canonical or legacy patch events.

Co-authored-by: Okan YILDIRIM <63511519+ragokan@users.noreply.github.com>

* fix(codex): preserve patch preview labels and move destinations

Keep complete file and hunk labels within the preview budget and count files omitted for space. Select the primary file from rendered content and retain move destinations in path-keyed and legacy events.

* fix(codex): bound generated file additions without argument overflow

* fix(codex): retain move-only files in multi-file previews

Represent moves without content changes as rename metadata and remove the provider move notice before classifying diff contents.

* fix(codex): keep move normalization inside multi-file previews

Recognize the provider move notice only while assembling multi-file diffs so the existing single-file path continues to retain its destination.

* fix(codex): retain empty additions in multi-file previews

---------

Co-authored-by: Okan YILDIRIM <63511519+ragokan@users.noreply.github.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-03 04:06:44 +00:00
85 changed files with 2939 additions and 250 deletions
+5 -4
View File
@@ -1,8 +1,8 @@
# Changelog
## 0.15.0-beta.1 - 2026-11-01
## 0.15.0 - 2026-11-03
Daemon restarts now keep terminal programs running and automatically resume interrupted agents in their existing conversations.
Daemon restarts now keep terminal programs running. Interrupted agents can resume in their existing conversations when you turn on agent recovery.
Mobile terminals have native rendering and draggable text-selection handles. Reconnecting restores up to 10,000 lines of terminal history.
@@ -13,7 +13,7 @@ Worktree setup can copy ignored files and use plugin-defined locations before ag
- Insert known agent commands from replies into the Composer draft ([#8371](https://github.com/getpaseo/paseo/pull/8371))
- Reuse running OpenCode v2 services ([#7435](https://github.com/getpaseo/paseo/pull/7435) by [@mcowger](https://github.com/mcowger))
- Kept terminal programs running across daemon restarts, crashes and supported updates ([#7644](https://github.com/getpaseo/paseo/pull/7644), [#8155](https://github.com/getpaseo/paseo/pull/8155) by [@sebastianarena](https://github.com/sebastianarena))
- Automatically resume interrupted agents in their existing conversations after a host restart ([#8127](https://github.com/getpaseo/paseo/pull/8127) by [@coolwindX](https://github.com/coolwindX), [@suntianc](https://github.com/suntianc))
- Added an opt-in setting to resume interrupted agents in their existing conversations after a host restart ([#8127](https://github.com/getpaseo/paseo/pull/8127), [#8619](https://github.com/getpaseo/paseo/pull/8619) by [@coolwindX](https://github.com/coolwindX), [@suntianc](https://github.com/suntianc))
- Added draggable terminal text-selection handles on iOS and Android ([#8348](https://github.com/getpaseo/paseo/pull/8348))
- Let phones and tablets rotate with the system orientation setting ([#8256](https://github.com/getpaseo/paseo/pull/8256) by [@magiclaw](https://github.com/magiclaw), [@yzim](https://github.com/yzim))
- Copy ignored files selected by `.worktreeinclude` into new worktrees before setup and agents start ([#8100](https://github.com/getpaseo/paseo/pull/8100) by [@cprecioso](https://github.com/cprecioso))
@@ -64,6 +64,7 @@ Worktree setup can copy ignored files and use plugin-defined locations before ag
- Fixed the MiniMax Code catalog command failing to select an executable ([#8201](https://github.com/getpaseo/paseo/pull/8201) by [@Peanut-HZM](https://github.com/Peanut-HZM))
- Fixed Android keeping the wrong layout after folding or unfolding while backgrounded ([#8285](https://github.com/getpaseo/paseo/pull/8285))
- Fixed Android workspace layouts and header fades retaining their old size after rotation ([#8349](https://github.com/getpaseo/paseo/pull/8349), [#8377](https://github.com/getpaseo/paseo/pull/8377))
- Fixed chat reconnecting and updating status being hidden behind the header on iOS and Android phones ([#8568](https://github.com/getpaseo/paseo/pull/8568))
- Fixed deleted agents leaving unclosable tabs in another window ([#8248](https://github.com/getpaseo/paseo/pull/8248) by [@loganmeyer](https://github.com/loganmeyer))
- Fixed transcript text retaining old theme colors after the theme changes while the window is hidden ([#8199](https://github.com/getpaseo/paseo/pull/8199) by [@Nurshot](https://github.com/Nurshot), [@dansabin](https://github.com/dansabin))
- Fixed web menus snapping back to their loading size after content arrives ([#8180](https://github.com/getpaseo/paseo/pull/8180) by [@babo-jeonguk](https://github.com/babo-jeonguk))
@@ -74,7 +75,7 @@ Worktree setup can copy ignored files and use plugin-defined locations before ag
### Notes
- The first upgrade from 0.14.0 still closes terminals; terminal preservation starts once the new daemon is installed, and Windows desktop app upgrades still close them
- Automatic agent recovery is on by default and sends `Resume working.`; change the prompt or disable it in Host settings → Agents
- Agent recovery is off by default; turn on **Resume agents after restart** in Host settings → Agents to send `Resume working.` or your own prompt
- Before downgrading to 0.14.0, restore a backed-up `config.json` or remove the saved agent-resume and usage-check settings
## 0.14.0 - 2026-10-29
+5 -4
View File
@@ -1,8 +1,8 @@
# Changelog
## 0.15.0-beta.1 - 2026-11-01
## 0.15.0 - 2026-11-03
daemon 重启后,终端中的程序继续运行,被中断的 Agent 会自动在原有会话中恢复工作。
daemon 重启后,终端中的程序继续运行。开启 Agent 恢复后,被中断的 Agent 会在原有会话中恢复工作。
移动端终端采用原生渲染,并提供可拖动的文本选择手柄。重新连接时最多恢复 10,000 行终端历史。
@@ -13,7 +13,7 @@ worktree 初始化支持复制被忽略的文件,并在 Agent 启动前使用
- 点击回复中的已知 Agent 命令,将其插入 Composer 草稿 ([#8371](https://github.com/getpaseo/paseo/pull/8371))
- 复用已运行的 OpenCode v2 服务 ([#7435](https://github.com/getpaseo/paseo/pull/7435),贡献者:[@mcowger](https://github.com/mcowger))
- 让终端中的程序在 daemon 重启、崩溃和支持的更新过程中继续运行 ([#7644](https://github.com/getpaseo/paseo/pull/7644), [#8155](https://github.com/getpaseo/paseo/pull/8155),贡献者:[@sebastianarena](https://github.com/sebastianarena))
- 主机重启后,自动在原有会话中恢复被中断的 Agent ([#8127](https://github.com/getpaseo/paseo/pull/8127),贡献者:[@coolwindX](https://github.com/coolwindX), [@suntianc](https://github.com/suntianc))
- 新增可选设置,在主机重启后于原有会话中恢复被中断的 Agent ([#8127](https://github.com/getpaseo/paseo/pull/8127), [#8619](https://github.com/getpaseo/paseo/pull/8619),贡献者:[@coolwindX](https://github.com/coolwindX), [@suntianc](https://github.com/suntianc))
- 在 iOS 和 Android 终端中新增可拖动的文本选择手柄 ([#8348](https://github.com/getpaseo/paseo/pull/8348))
- 让手机和平板遵循系统屏幕旋转设置 ([#8256](https://github.com/getpaseo/paseo/pull/8256),贡献者:[@magiclaw](https://github.com/magiclaw), [@yzim](https://github.com/yzim))
- 通过 `.worktreeinclude` 选择要复制到新 worktree 的被忽略文件,在初始化和 Agent 启动前完成复制 ([#8100](https://github.com/getpaseo/paseo/pull/8100),贡献者:[@cprecioso](https://github.com/cprecioso))
@@ -64,6 +64,7 @@ worktree 初始化支持复制被忽略的文件,并在 Agent 启动前使用
- 修复了 MiniMax Code 目录命令无法选定可执行文件的问题 ([#8201](https://github.com/getpaseo/paseo/pull/8201),贡献者:[@Peanut-HZM](https://github.com/Peanut-HZM))
- 修复了 Android 应用在后台折叠或展开后保留错误布局的问题 ([#8285](https://github.com/getpaseo/paseo/pull/8285))
- 修复了 Android 屏幕旋转后工作区布局和顶栏渐变保留旧尺寸的问题 ([#8349](https://github.com/getpaseo/paseo/pull/8349), [#8377](https://github.com/getpaseo/paseo/pull/8377))
- 修复了 iOS 和 Android 手机聊天中重新连接和更新状态被顶栏遮挡的问题 ([#8568](https://github.com/getpaseo/paseo/pull/8568))
- 修复了删除 Agent 后,另一个窗口留下无法关闭的标签页的问题 ([#8248](https://github.com/getpaseo/paseo/pull/8248),贡献者:[@loganmeyer](https://github.com/loganmeyer))
- 修复了窗口隐藏期间切换主题后,聊天文本保留旧主题颜色的问题 ([#8199](https://github.com/getpaseo/paseo/pull/8199),贡献者:[@Nurshot](https://github.com/Nurshot), [@dansabin](https://github.com/dansabin))
- 修复了网页菜单加载内容后跳回加载时尺寸的问题 ([#8180](https://github.com/getpaseo/paseo/pull/8180),贡献者:[@babo-jeonguk](https://github.com/babo-jeonguk))
@@ -74,7 +75,7 @@ worktree 初始化支持复制被忽略的文件,并在 Agent 启动前使用
### 说明
- 首次从 0.14.0 升级时仍会关闭终端;安装新 daemon 后才支持保留终端,Windows 桌面应用升级仍会关闭终端
- Agent 自动恢复默认开启,并发送 `Resume working.`;可在主机设置 → Agent 中修改提示词或关闭此功能
- Agent 恢复默认关闭;在主机设置 → Agent 中开启 **重启后恢复智能体**,即可发送 `Resume working.` 或自定义提示词
- 降级到 0.14.0 前,请恢复备份的 `config.json`,或删除已保存的 Agent 恢复和用量查询设置
## 0.14.0 - 2026-10-29
+16 -13
View File
@@ -97,19 +97,15 @@ Register the adapter in `defaultForgeRegistry` with:
- `matchesHost` from manifest `cloudHosts`
- `probeHost` when self-hosted/Enterprise detection is supported
Current change-request lookup uses two identities deliberately:
Current change-request lookup matches the branch and repository for both open and
terminal requests. Prefer an open request, then a terminal request at the local
HEAD, then the most recently updated terminal request. A reused branch can show
its previous request until a new one opens. Auto-archive separately requires an
observed open-to-merged transition and a fresh exact match between checkout HEAD
and the PR head, along with the existing workspace safety checks.
- An open PR/MR belongs to the checkout when its head branch and head repository
match. Its remote head SHA may differ because the checkout can be ahead,
behind, or contain commits that have not been pushed yet.
- A merged or closed PR/MR belongs to the checkout only when its recorded head
SHA exactly matches the checkout's current `HEAD`. Branch names are reusable;
selecting the newest terminal request by branch alone can silently attach an
old promotion or feature request to new work.
Thread the checkout head SHA through adapter cache and poll identities as well
as the lookup itself. Otherwise a commit made on the same branch can inherit the
previous commit's cached terminal status until the cache expires.
Keep checkout HEAD in adapter cache and poll identities so checkout changes
refresh the facts used by archive eligibility.
Cloud hosts in the manifest are a bounded public-host list, not a self-host
allowlist. Self-hosted detection is a trust gate: Paseo only talks to a forge
@@ -125,7 +121,8 @@ rendering stack:
`acme.ts` exports a `ClientForgeLogicModule`:
- `id`
- optional `urlGrammar` (including `referencePaths` for composer auto-attachment)
- optional `urlGrammar` (including `referencePaths` for composer auto-attachment
and Markdown shorthand references)
- optional `facts` registration (schema, merge-capability, native-check fallbacks)
`acme.view.tsx` exports a `ClientForgeViewModule`:
@@ -212,6 +209,12 @@ are stale, run `npm run build:server`.
neutral extractor matches the pasted host and full repository path against
the current remote before it invokes cwd-routed forge search. Do not add a
central forge switch or guess a self-hosted Gitea-family brand from URL shape.
- Shorthand references in rendered Markdown (`#12`, GitLab and Gitea `!12`,
cross-repository `owner/repo#12`) come from the `sigil` on each
`referencePaths` entry. Leave `sigil` off a kind the forge does not link in
prose: GitHub has no `!`, and its `#` targets `/issues/`, which GitHub
redirects to the pull request. `packages/app/src/git/forge-shorthand` builds
the recognition pattern from the workspace descriptor's forge and remote.
- GitLab pipeline status constants belong to the GitLab adapter/client module,
not protocol.
- GitLab `manual` jobs need `allow_failure` to distinguish optional gray jobs
+2 -2
View File
@@ -10,9 +10,9 @@ Every block is separated by one gap, `density.blockGap`: 12 on compact layouts,
## Links and images
Workspace file links use the shared file-link resolver. The workspace supplies its client, root and open-file action once; chat can supply the agent's more specific context. URLs use the same link press path. Recognized inline-code paths stay tappable. The library callback carries the tapped occurrence’s syntax context. Preserve it when opening files: explicit Markdown links decode URL escapes, while recognized inline-code paths preserve literal percent characters and use suffix lookup. Identical URL strings in different occurrences must stay distinct. Explicit absolute workspace links receive a relative hover title; existing authored titles remain intact.
Workspace file links use the shared file-link resolver. The workspace supplies its client, root and open-file action once; chat can supply the agent's more specific context. URLs use the same link press path. Recognized inline-code paths stay tappable. The library callback carries the tapped occurrence’s syntax context. Preserve it when opening files: explicit Markdown links decode URL escapes, while recognized inline-code paths preserve literal percent characters and use suffix lookup. Identical URL strings in different occurrences must stay distinct. Explicit absolute workspace links receive a relative hover title; existing authored titles remain intact. Inside a workspace, forge references such as `#12` and `owner/repo#12` are recognized in prose (not code) and open the workspace forge; the reference pattern comes before path recognition so `group/project#12` is not read as a path. See [forge providers](forge-providers.md#gotchas).
Resolve workspace image paths over the existing session RPC, then give the library a cached native file URL or web blob URL. HTTP(S) images pass through unchanged. This preserves relay, SSH and local socket connections, which do not expose a daemon HTTP endpoint. Each mounted renderer owns its image acquisition identity, so a later reply reads a rewritten path again while the earlier mounted reply retains its snapshot. Image taps open the attachment lightbox. File previews resolve relative images against the Markdown file's directory. An image alone on its line becomes its own paragraph before the library sees it, so it renders as a block on every platform; the library only treats sole-child paragraph images as blocks, and with hard soft breaks every newline is a break node, which left own-line images inline on iOS and web. Images that share a line with text stay inline.
Resolve workspace image paths over the existing session RPC, then give the library a cached native file URL or web blob URL. HTTP(S) images pass through unchanged. This preserves relay, SSH and local socket connections, which do not expose a daemon HTTP endpoint. Each mounted renderer owns its image acquisition identity, so a later reply reads a rewritten path again while the earlier mounted reply retains its snapshot. Image taps open the attachment lightbox. File previews resolve relative images against the Markdown file's directory. An image alone on its line becomes its own paragraph before the library sees it, so it renders as a block on every platform; the library only treats sole-child paragraph images as blocks, and with hard soft breaks every newline is a break node, which left own-line images inline on iOS and web. Images that share a line with text stay inline at line height and keep their aspect ratio. A linked image counts as an image, so review-bot badges such as `[![Retrigger](badge.svg)](url) Confidence` stay inline on web; the library's web renderer only checked direct paragraph children, so it rendered them as full-width block images (local patch in `patches/react-native-enriched-markdown+*.patch`).
## Engine constraints
@@ -0,0 +1,10 @@
Daemon restarts now keep terminal programs running. Interrupted agents can resume in their existing conversations when you turn on agent recovery.
Mobile terminals have native rendering and draggable text-selection handles. Reconnecting restores up to 10,000 lines of terminal history.
Worktree setup can copy ignored files and use plugin-defined locations before agents start.
Added
- Insert known agent commands from replies into the Composer draft
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,10 @@
Daemon restarts now keep terminal programs running. Interrupted agents can resume in their existing conversations when you turn on agent recovery.
Mobile terminals have native rendering and draggable text-selection handles. Reconnecting restores up to 10,000 lines of terminal history.
Worktree setup can copy ignored files and use plugin-defined locations before agents start.
Added
- Insert known agent commands from replies into the Composer draft
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,10 @@
Daemon restarts now keep terminal programs running. Interrupted agents can resume in their existing conversations when you turn on agent recovery.
Mobile terminals have native rendering and draggable text-selection handles. Reconnecting restores up to 10,000 lines of terminal history.
Worktree setup can copy ignored files and use plugin-defined locations before agents start.
Added
- Insert known agent commands from replies into the Composer draft
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,10 @@
Daemon restarts now keep terminal programs running. Interrupted agents can resume in their existing conversations when you turn on agent recovery.
Mobile terminals have native rendering and draggable text-selection handles. Reconnecting restores up to 10,000 lines of terminal history.
Worktree setup can copy ignored files and use plugin-defined locations before agents start.
Added
- Insert known agent commands from replies into the Composer draft
Full notes: https://paseo.sh/changelog
+1 -1
View File
@@ -1 +1 @@
sha256-KN/AVZV/o0TleLLpmggSrdJqjFDJ/NJYVHiC54xb4jQ=
sha256-iAi82L6Lm75hdg/9tJYyYcHMhCJLtXGSpcNqgv9QQaw=
+28 -28
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.15.0-beta.1",
"version": "0.15.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.15.0-beta.1",
"version": "0.15.0",
"hasInstallScript": true,
"license": "Apache-2.0",
"workspaces": [
@@ -36762,7 +36762,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.15.0-beta.1",
"version": "0.15.0",
"dependencies": {
"@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4",
@@ -38930,12 +38930,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.15.0-beta.1",
"version": "0.15.0",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.15.0-beta.1",
"@getpaseo/protocol": "0.15.0-beta.1",
"@getpaseo/server": "0.15.0-beta.1",
"@getpaseo/client": "0.15.0",
"@getpaseo/protocol": "0.15.0",
"@getpaseo/server": "0.15.0",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -39182,10 +39182,10 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.15.0-beta.1",
"version": "0.15.0",
"dependencies": {
"@getpaseo/protocol": "0.15.0-beta.1",
"@getpaseo/relay": "0.15.0-beta.1",
"@getpaseo/protocol": "0.15.0",
"@getpaseo/relay": "0.15.0",
"ws": "^8.20.0",
"zod": "^4.4.3"
},
@@ -39197,7 +39197,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.15.0-beta.1",
"version": "0.15.0",
"license": "Apache-2.0",
"dependencies": {
"@getpaseo/cli": "*",
@@ -40034,7 +40034,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.15.0-beta.1",
"version": "0.15.0",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -40080,7 +40080,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.15.0-beta.1",
"version": "0.15.0",
"dependencies": {
"@codemirror/language": "6.12.4",
"@codemirror/legacy-modes": "^6.5.3",
@@ -40109,15 +40109,15 @@
},
"packages/plugin": {
"name": "@getpaseo/plugin",
"version": "0.15.0-beta.1",
"version": "0.15.0",
"dependencies": {
"@agentclientprotocol/sdk": "^1.4.0",
"i18next": "^26.3.0",
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.15.0-beta.1",
"@getpaseo/protocol": "0.15.0-beta.1",
"@getpaseo/client": "0.15.0",
"@getpaseo/protocol": "0.15.0",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.2.3",
@@ -40127,8 +40127,8 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.15.0-beta.1",
"@getpaseo/protocol": "0.15.0-beta.1",
"@getpaseo/client": "0.15.0",
"@getpaseo/protocol": "0.15.0",
"react": ">=19.1.0 <20",
"react-native": ">=0.81.5",
"zod": "^4.4.3"
@@ -40150,7 +40150,7 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.15.0-beta.1",
"version": "0.15.0",
"dependencies": {
"semver": "^7.8.5",
"zod": "^4.4.3"
@@ -40177,7 +40177,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.15.0-beta.1",
"version": "0.15.0",
"dependencies": {
"@noble/ciphers": "^2.4.0",
"@noble/curves": "^2.4.0",
@@ -40398,16 +40398,16 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.15.0-beta.1",
"version": "0.15.0",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.15.0-beta.1",
"@getpaseo/highlight": "0.15.0-beta.1",
"@getpaseo/plugin": "0.15.0-beta.1",
"@getpaseo/protocol": "0.15.0-beta.1",
"@getpaseo/relay": "0.15.0-beta.1",
"@getpaseo/client": "0.15.0",
"@getpaseo/highlight": "0.15.0",
"@getpaseo/plugin": "0.15.0",
"@getpaseo/protocol": "0.15.0",
"@getpaseo/relay": "0.15.0",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
@@ -41354,7 +41354,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.15.0-beta.1",
"version": "0.15.0",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
@@ -42964,7 +42964,7 @@
},
"plugins": {
"name": "@getpaseo/builtin-plugins",
"version": "0.15.0-beta.1",
"version": "0.15.0",
"dependencies": {
"@getpaseo/plugin": "*",
"zod": "^4.4.3"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.15.0-beta.1",
"version": "0.15.0",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
@@ -27,6 +27,7 @@ import { seedWorkspace } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
import { installUsageReportsFixture } from "../support/helpers/usage-reports";
import { claudeAndCodexReports } from "../support/helpers/usage-sidebar-item";
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
const APP_SETTINGS_KEY = "@paseo:app-settings";
@@ -133,6 +134,30 @@ function sidebarFooter(page: Page): Locator {
return visibleTestId(page, "sidebar-add-project").locator("xpath=..");
}
function isPluginCatalogResponse(message: string | Buffer): boolean {
if (typeof message !== "string") return false;
const frame = JSON.parse(message);
return frame.type === "session" && frame.message?.type === "plugin.catalog.get.response";
}
async function holdNextPluginCatalogResponse(page: Page) {
const response = Promise.withResolvers<() => void>();
let holdCatalog = true;
await page.routeWebSocket(daemonWsRoutePattern(), (socket) => {
const server = socket.connectToServer();
socket.onMessage((message) => server.send(message));
server.onMessage((message) => {
if (holdCatalog && isPluginCatalogResponse(message)) {
holdCatalog = false;
response.resolve(() => socket.send(message));
return;
}
socket.send(message);
});
});
return { response: response.promise };
}
test.describe("Plugin sidebar items", () => {
let workspaceId: string;
let cleanup: () => Promise<void>;
@@ -151,6 +176,38 @@ test.describe("Plugin sidebar items", () => {
await cleanup();
});
test("a restored plugin screen waits for its catalog before rendering", async ({ page }) => {
await page.setViewportSize(WIDE);
await gotoWorkspace(page, workspaceId);
const row = headerRow(page, SHOWCASE_PLUGIN_ID, "deploys");
await expect(row).toBeVisible({ timeout: 30_000 });
await row.click();
const body = page.getByText("Deploys screen body", { exact: true });
await expect(body).toBeVisible();
const screenUrl = page.url();
const catalog = await holdNextPluginCatalogResponse(page);
await page.reload();
const releaseCatalog = await catalog.response;
try {
await expect(
page.getByRole("progressbar", { name: "Loading plugin", exact: true }),
).toBeVisible();
await expect(
page.getByText("This plugin surface is unavailable.", { exact: true }),
).toHaveCount(0);
await expect(body).toHaveCount(0);
} finally {
releaseCatalog();
}
await expect(body).toBeVisible();
await expect(
page.getByRole("progressbar", { name: "Loading plugin", exact: true }),
).toHaveCount(0);
await expect(page).toHaveURL(screenUrl);
});
test("a plugin's screen, header row, footer popover and command work together", async ({
page,
}) => {
+36 -3
View File
@@ -3,6 +3,7 @@ import path from "node:path";
import {
holdOlderHistoryPages,
userScrollsTimelineToHistoryStart,
waitForTimelineGeometryToSettle,
} from "../support/helpers/timeline-pagination";
import { getActiveTabTestId } from "../support/helpers/launcher";
import type { Page, Locator } from "@playwright/test";
@@ -407,6 +408,38 @@ async function seedWholeTurnHistory() {
return agent;
}
/** Read every command across the scroll window, without requiring offscreen rows to mount. */
async function expectExpandedCommandsReadable(page: Page, count: number) {
const timeline = page.getByTestId("agent-chat-scroll");
const commands = new Set<number>();
await timeline.hover();
for (let step = 0; step < count && commands.size < count; step += 1) {
const labels = await timeline
.getByRole("button", { name: /echo stress-\d+/ })
.allTextContents();
for (const label of labels) {
const command = /echo stress-(\d+)/.exec(label);
if (command) commands.add(Number(command[1]));
}
if (commands.size < count) {
await page.mouse.wheel(0, 300);
await waitForTimelineGeometryToSettle(page);
}
}
expect([...commands].sort((a, b) => a - b)).toEqual(
Array.from({ length: count }, (_, index) => index),
);
}
async function returnToExpandedSummary(page: Page, summary: Locator) {
await page.getByTestId("agent-chat-scroll").hover();
for (let step = 0; step < 120 && !(await summary.isVisible()); step += 1) {
await page.mouse.wheel(0, -300);
await waitForTimelineGeometryToSettle(page);
}
await summary.scrollIntoViewIfNeeded();
}
test("loads complete long turns and preserves expansion while revealing older history", async ({
page,
}) => {
@@ -423,7 +456,7 @@ test("loads complete long turns and preserves expansion while revealing older hi
await expect(latest).toBeVisible();
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0);
await latest.click();
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(120);
await expectExpandedCommandsReadable(page, 120);
await userScrollsTimelineToHistoryStart(page);
await history.expectRequestedPages(1);
history.releasePage(1);
@@ -432,9 +465,9 @@ test("loads complete long turns and preserves expansion while revealing older hi
.getByTestId("turn-summary")
.getByRole("button", { name: "Ran 60 commands", exact: true }),
).toBeVisible();
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(120);
await expectExpandedCommandsReadable(page, 120);
history.expectNoRepeatedEntries();
await latest.scrollIntoViewIfNeeded();
await returnToExpandedSummary(page, latest);
await latest.click();
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0);
} finally {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.15.0-beta.1",
"version": "0.15.0",
"private": true,
"main": "index.ts",
"scripts": {
@@ -26,14 +26,22 @@ export function getMountedRecentStreamItems(): number {
export function findMountedWindowStart(input: {
items: StreamItem[];
minMountedCount: number;
// Caps how far turn alignment may rewind. History paging and the replica
// cache omit it and still keep a whole turn.
maxMountedCount?: number;
}): number {
const { items, minMountedCount } = input;
if (items.length <= minMountedCount) {
return 0;
}
const mountedLimit =
input.maxMountedCount === undefined
? items.length
: Math.max(minMountedCount, input.maxMountedCount);
const earliestStart = Math.max(0, items.length - mountedLimit);
let startIndex = Math.max(items.length - minMountedCount, 0);
while (startIndex > 0 && continuesTurn(items[startIndex - 1], items[startIndex])) {
while (startIndex > earliestStart && continuesTurn(items[startIndex - 1], items[startIndex])) {
startIndex -= 1;
}
return startIndex;
@@ -3,6 +3,7 @@ import type { StreamItem } from "@/types/stream";
import { projectPluginTimelineItems } from "@/plugins/timeline/projection";
import { findMountedWindowStart } from "./history-window";
import { buildAgentStreamRenderModel } from "./model";
import { MAX_MOUNTED_WEB_WINDOW_ITEMS } from "./web-virtualization";
function createTimestamp(seed: number): Date {
return new Date(`2026-01-01T00:00:${seed.toString().padStart(2, "0")}.000Z`);
@@ -125,6 +126,30 @@ describe("buildAgentStreamRenderModel", () => {
expect(model.history).not.toContain(head[0]);
});
it("virtualizes a desktop web turn that runs past the mounted cap", () => {
const length = MAX_MOUNTED_WEB_WINDOW_ITEMS + 80;
const tail = Array.from({ length }, (_, index) => ({
...userMessage(`row-${index}`, index % 60),
turnId: "long-run",
}));
const model = buildAgentStreamRenderModel({
isTurnActive: false,
activeTurnStartedAt: null,
tail,
head: [],
platform: "web",
isMobileBreakpoint: false,
});
expect(model.segments.historyMounted).toHaveLength(MAX_MOUNTED_WEB_WINDOW_ITEMS);
expect(model.segments.historyVirtualized).toHaveLength(80);
expect(model.segments.historyMounted[0]?.id).toBe(
`row-${length - MAX_MOUNTED_WEB_WINDOW_ITEMS}`,
);
expect(model.segments.historyVirtualized[0]?.id).toBe("row-0");
});
it("keeps the full committed tail mounted on mobile web", () => {
const tail = [userMessage("u1", 1), assistantMessage("a1", 2)];
const head = [assistantMessage("live-a", 3)];
+5 -1
View File
@@ -2,7 +2,10 @@ import type { ReactNode } from "react";
import { deriveStreamTurnTiming, type StreamTurnTiming } from "@/timeline/turn-time";
import type { StreamItem } from "@/types/stream";
import { findMountedWindowStart, getMountedRecentStreamItems } from "./history-window";
import { getWebPartialVirtualizationThreshold } from "./web-virtualization";
import {
getWebPartialVirtualizationThreshold,
MAX_MOUNTED_WEB_WINDOW_ITEMS,
} from "./web-virtualization";
import { orderHeadForStreamRenderStrategy, orderTailForStreamRenderStrategy } from "./strategy";
import { resolveStreamRenderStrategy } from "./strategy-resolver";
@@ -134,6 +137,7 @@ function splitOrderedTail(params: {
const mountedWindowStart = findMountedWindowStart({
items: orderedTail,
minMountedCount: getMountedRecentStreamItems(),
maxMountedCount: MAX_MOUNTED_WEB_WINDOW_ITEMS,
});
const split = {
history: orderedTail,
@@ -10,6 +10,7 @@ import {
DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD,
estimateStreamItemHeight,
findMountedWindowStart,
MAX_MOUNTED_WEB_WINDOW_ITEMS,
getWebMountedRecentStreamItems,
getWebPartialVirtualizationThreshold,
splitWebVirtualizedHistory,
@@ -108,6 +109,37 @@ describe("findMountedWindowStart", () => {
}),
).toBe(39);
});
it("keeps a whole turn when the caller does not cap the rewind", () => {
const items = Array.from({ length: MAX_MOUNTED_WEB_WINDOW_ITEMS + 40 }, (_, index) => ({
...toolCall(`t${index}`, index % 60),
turnId: "long",
}));
expect(findMountedWindowStart({ items, minMountedCount: 20 })).toBe(0);
});
it("stops a long turn at the cap and still honors a larger minimum", () => {
const items = Array.from({ length: 200 }, (_, index) => ({
...toolCall(`t${index}`, index % 60),
turnId: "long",
}));
expect(
findMountedWindowStart({
items,
minMountedCount: 20,
maxMountedCount: MAX_MOUNTED_WEB_WINDOW_ITEMS,
}),
).toBe(200 - MAX_MOUNTED_WEB_WINDOW_ITEMS);
expect(
findMountedWindowStart({
items,
minMountedCount: 80,
maxMountedCount: MAX_MOUNTED_WEB_WINDOW_ITEMS,
}),
).toBe(120);
});
});
describe("splitWebVirtualizedHistory", () => {
@@ -130,6 +162,39 @@ describe("splitWebVirtualizedHistory", () => {
expect(window.mountedEntries[0]?.item.id).toBe("u5");
expect(window.mountedEntries).toHaveLength(50);
});
it("virtualizes the older rows of a turn that is longer than the mounted cap", () => {
const length = MAX_MOUNTED_WEB_WINDOW_ITEMS + 25;
const items = Array.from({ length }, (_, index) => ({
...toolCall(`t${index}`, index % 60),
turnId: "long",
}));
const window = splitWebVirtualizedHistory({
entries: indexEntries(items),
minMountedCount: DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS,
});
expect(window.mountedEntries).toHaveLength(MAX_MOUNTED_WEB_WINDOW_ITEMS);
expect(window.virtualizedEntries).toHaveLength(25);
expect(window.mountedEntries[0]?.item.id).toBe(`t${length - MAX_MOUNTED_WEB_WINDOW_ITEMS}`);
expect(window.virtualizedEntries[0]?.item.id).toBe("t0");
});
it("keeps a turn that fits under the cap mounted together", () => {
const items = Array.from({ length: 40 }, (_, index) => ({
...toolCall(`t${index}`, index),
turnId: "short",
}));
const window = splitWebVirtualizedHistory({
entries: indexEntries(items),
minMountedCount: DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS,
});
expect(window.virtualizedEntries).toHaveLength(0);
expect(window.mountedEntries).toHaveLength(40);
});
});
describe("estimateStreamItemHeight", () => {
@@ -8,6 +8,9 @@ import {
export const DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD = 100;
export const DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS = DEFAULT_MOUNTED_RECENT_STREAM_ITEMS;
// Collapsed tool rows are about 40px, so this stays past a desktop viewport.
// Opening a long run mounts that tail and virtualizes the older rows.
export const MAX_MOUNTED_WEB_WINDOW_ITEMS = 60;
const COLLAPSED_TOOL_SEQUENCE_ROW_HEIGHT_ESTIMATE = 40;
type BottomAnchorE2ETestGlobals = typeof globalThis & {
@@ -86,6 +89,7 @@ export function splitWebVirtualizedHistory(input: {
const startIndex = findMountedWindowStart({
items: input.entries.map((entry) => entry.item),
minMountedCount: input.minMountedCount,
maxMountedCount: MAX_MOUNTED_WEB_WINDOW_ITEMS,
});
return {
virtualizedEntries: input.entries.slice(0, startIndex),
@@ -38,12 +38,18 @@ export interface MarkdownImageContext {
export function useMarkdownSource(
text: string,
context: MarkdownImageContext,
commandNames?: ReadonlySet<string>,
links: { commandNames?: ReadonlySet<string>; forgeReferencePattern?: string } = {},
) {
const occurrenceKey = useId();
const { commandNames, forgeReferencePattern } = links;
const prepared = useMemo(
() => prepareMarkdownSource(text, context.workspaceRoot, { supportsSvg: isWeb, commandNames }),
[text, context.workspaceRoot, commandNames],
() =>
prepareMarkdownSource(text, context.workspaceRoot, {
supportsSvg: isWeb,
commandNames,
forgeReferencePattern,
}),
[text, context.workspaceRoot, commandNames, forgeReferencePattern],
);
const sources = [...new Set(prepared.images.map((image) => image.source))];
const acquisitions = sources.map((source) => {
@@ -3,6 +3,7 @@ import { toNativeLinkRecognition } from "react-native-enriched-markdown/src/util
import { parseMarkdown } from "react-native-enriched-markdown/src/web/parseMarkdown";
import { recognizeTextLinks } from "react-native-enriched-markdown/src/web/recognizeTextLinks";
import { matchCommandLink } from "@/agent-commands";
import { createForgeShorthand } from "@/git/forge-shorthand";
import { classifyAssistantFileLink } from "@/assistant-file-links";
import { markInlineImages } from "react-native-enriched-markdown/src/web/utils";
@@ -110,6 +111,37 @@ describe("shared Markdown source adaptation", () => {
["https://x/q.png", false],
]);
});
it("renders linked images that share a line with text inline", async () => {
// A review bot's summary header: linked badges inside a heading with the score text.
const reviewHeader =
'<h2><a href="https://x/retrigger"><picture><source media="(prefers-color-scheme: dark)" srcset="https://x/r-dark.svg"><img alt="Retrigger" src="https://x/r.svg" align="right"></picture></a>Confidence Score: 5/5</h2>';
const source = [
reviewHeader,
"[![Alone](https://x/alone.png)](https://x)",
"[![a](https://x/a.png)](https://x) [![b](https://x/b.png)](https://x)",
"See [![gh](https://x/gh.png) GitHub](https://x) for more.",
].join("\n\n");
const tree = await parseMarkdown(prepareMarkdownSource(source).render(), {
hardSoftBreaks: true,
latexMath: false,
});
markInlineImages(tree);
const images: ASTNode[] = [];
const collect = (node: ASTNode) => {
if (node.type === "Image") images.push(node);
node.children?.forEach(collect);
};
collect(tree);
expect(
images.map((image) => [image.attributes?.url, image.attributes?.isInline === true]),
).toEqual([
["https://x/r.svg", true],
["https://x/alone.png", false],
["https://x/a.png", true],
["https://x/b.png", true],
["https://x/gh.png", true],
]);
});
it("escapes spoiler delimiters only in prose", () => {
expect(prepareMarkdownSource("Show ||words|| and `||code||`.").render()).toBe(
"Show \\|\\|words\\|\\| and `||code||`.",
@@ -412,6 +444,47 @@ it("lets the engine own code boundaries while classifying candidate values", asy
]);
});
it("recognizes forge references in prose but not in code, links or paths", async () => {
const shorthand = createForgeShorthand({
forge: "gitlab",
remoteUrl: "git@gitlab.com:g/p.git",
})!;
const source = [
"Fixed in #12 and !13, see g/sub/q#14 and https://example.com/a#15.",
"`#16` and [#17](https://example.com) and src/app/index.ts and src/a/b/c#18",
"```\n#19\n```",
].join("\n\n");
const prepared = prepareMarkdownSource(source, "/repo", {
forgeReferencePattern: shorthand.pattern,
});
const tree = recognizeTextLinks(
await parseMarkdown(prepared.render(), { latexMath: false }),
prepared.linkRecognition.text,
prepared.linkRecognition.inlineCode,
);
const recognized: string[] = [];
function collect(node: ASTNode) {
if (node.type === "Link" && node.attributes?.recognizedLink === "true") {
recognized.push(String(node.attributes.url));
}
node.children?.forEach(collect);
}
collect(tree);
expect(recognized).toEqual(["#12", "!13", "g/sub/q#14", "src/app/index.ts", "src/a/b/c#18"]);
expect(recognized.map((url) => shorthand.resolve(url))).toEqual([
"https://gitlab.com/g/p/-/issues/12",
"https://gitlab.com/g/p/-/merge_requests/13",
"https://gitlab.com/g/sub/q/-/issues/14",
null,
"https://gitlab.com/src/a/b/c/-/issues/18",
]);
// A rejected native regex silently falls back to no recognition at all.
expect(toNativeLinkRecognition(prepared.linkRecognition).text).toMatchObject({
isDisabled: false,
isDefault: false,
});
});
describe("bare URL sentence punctuation", () => {
it.each([".", ",", ";", ":", "!", "?", ")", ").", "...", "?!"])(
"leaves %s outside the link and preserves the rendered sentence",
@@ -7,6 +7,8 @@ import {
} from "@/assistant-file-links";
import { useStableEvent } from "@/hooks/use-stable-event";
import { matchCommandLink, useAgentCommandHints, type CommandHints } from "@/agent-commands";
import { useForgeShorthand } from "@/git/forge-shorthand";
import { openExternalUrl } from "@/utils/open-external-url";
import { AttachmentLightbox, type ImageLightboxSource } from "@/components/attachment-lightbox";
import type { Theme } from "@/styles/theme";
import { isWeb } from "@/constants/platform";
@@ -89,6 +91,7 @@ const MarkdownPart = memo(function MarkdownPart({
}: MarkdownRendererProps & { commandHints?: CommandHints | null }) {
const environment = useAssistantFileLinkEnvironment();
const actions = useAssistantFileLinkActions();
const forgeShorthand = useForgeShorthand();
const client =
imageContext.client ??
(environment.client?.readFile
@@ -102,7 +105,7 @@ const MarkdownPart = memo(function MarkdownPart({
...imageContext,
client,
},
commandHints?.names,
{ commandNames: commandHints?.names, forgeReferencePattern: forgeShorthand?.pattern },
);
const [image, setImage] = useState<ImageLightboxSource | null>(null);
const pressLink = useStableEvent(({ url, sourceType }: LinkPressEvent) => {
@@ -111,6 +114,11 @@ const MarkdownPart = memo(function MarkdownPart({
commandHints.insert(command);
return;
}
const forgeUrl = sourceType === "text" ? forgeShorthand?.resolve(url) : null;
if (forgeUrl) {
void openExternalUrl(forgeUrl);
return;
}
const source = linkSource(url, sourceType);
if (onLinkPress && !actions.canResolveFile(source)) onLinkPress(url);
else actions.open(source, "preferred");
@@ -7,6 +7,7 @@ import type {
} from "react-native-enriched-markdown";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useFileLink } from "@/assistant-file-links";
import { useForgeShorthand } from "@/git/forge-shorthand";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import { HighlightedCodeBlock } from "@/components/highlighted-code-block";
import type { Theme } from "@/styles/theme";
@@ -31,7 +32,13 @@ function WebCodeBlock({ code, language }: CodeBlockRendererProps) {
const LINK_HOVER_STYLE = { textDecoration: "underline" } as const;
function WebLink({ link, anchorProps, children }: LinkRendererProps) {
const { tooltipPath, onHoverIn } = useFileLink(markdownLinkSource(link.url, link.sourceType));
const forgeShorthand = useForgeShorthand();
// A forge reference like `#12` carries its web URL so hover, copy and new-tab opens work.
const forgeUrl = link.sourceType === "text" ? forgeShorthand?.resolve(link.url) : null;
const { tooltipPath, onHoverIn } = useFileLink(
forgeUrl ? markdownLinkSource(forgeUrl) : markdownLinkSource(link.url, link.sourceType),
);
const tooltip = forgeUrl ?? tooltipPath;
const [hovered, setHovered] = useState(false);
const handleMouseEnter = useCallback(() => setHovered(true), []);
const handleMouseLeave = useCallback(() => setHovered(false), []);
@@ -46,6 +53,7 @@ function WebLink({ link, anchorProps, children }: LinkRendererProps) {
const anchor = (
<a
{...anchorProps}
href={forgeUrl ?? anchorProps.href}
style={anchorStyle}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
@@ -58,10 +66,10 @@ function WebLink({ link, anchorProps, children }: LinkRendererProps) {
<TooltipTrigger asChild onHoverIn={onHoverIn}>
<InlineLinkTrigger>{anchor}</InlineLinkTrigger>
</TooltipTrigger>
{tooltipPath ? (
{tooltip ? (
<TooltipContent side="top" align="start" maxWidth={520}>
<Text selectable={false} style={styles.tooltipPath}>
{tooltipPath}
{tooltip}
</Text>
</TooltipContent>
) : null}
+26 -6
View File
@@ -24,10 +24,12 @@ const FILE_NAME = String.raw`${PATH_SEGMENT}\.(?:${FILE_EXTENSIONS})`;
// Bare prose paths need a known extension, a line suffix, or at least two separators.
const PROSE_PATH = String.raw`(?:${PATH_PREFIX}${HAS_PATH_LETTER}${SEPARATED_PATH}|${HAS_PATH_LETTER}(?:${PATH_SEGMENT}(?:[\\/]${PATH_SEGMENT})*[\\/]${FILE_NAME}|${PATH_SEGMENT}(?:[\\/]${PATH_SEGMENT}){2,}|${SEPARATED_PATH}(?=:)))`;
function createFilePattern(path: string): RegExp {
function createTextPattern(path: string, forgeReferencePattern?: string): RegExp {
// Forge references come before paths so `group/project#12` is not read as a path.
const forgeReference = forgeReferencePattern ? `|${forgeReferencePattern}` : "";
// The native adapter mistakes a literal + in lookbehind for a quantifier; use its hex escape.
return new RegExp(
String.raw`(?:${BARE_URL}|(?<![a-zA-Z0-9\u0080-\uFFFF_@.%\x2b~:/\\-])(?:${path}|${FILE_NAME})${LINE_SUFFIX}(?![a-zA-Z0-9\u0080-\uFFFF_@%+:/\\-]|\.${PATH_CHARACTER}))`,
String.raw`(?:${BARE_URL}${forgeReference}|(?<![a-zA-Z0-9\u0080-\uFFFF_@.%\x2b~:/\\-])(?:${path}|${FILE_NAME})${LINE_SUFFIX}(?![a-zA-Z0-9\u0080-\uFFFF_@%+:/\\-]|\.${PATH_CHARACTER}))`,
"g",
);
}
@@ -38,8 +40,20 @@ const URL_CHARACTER = String.raw`[^\s<>()"'\x60]`;
const URL_GROUP = String.raw`\(${URL_CHARACTER}*\)`;
const BARE_URL = String.raw`https?://(?:${URL_CHARACTER}|${URL_GROUP})*(?:[^\s<>()"'\x60.,;:!?]|${URL_GROUP})`;
const FILE_PATTERN = createFilePattern(PROSE_PATH);
function linkMetadata(inlineCodePaths: ReadonlySet<string>) {
const TEXT_PATTERN = createTextPattern(PROSE_PATH);
// One entry per distinct workspace forge remote; reuse keeps the engine's regex identity stable.
const textPatternsByForgeReference = new Map<string, RegExp>();
function textPattern(forgeReferencePattern?: string): RegExp {
if (!forgeReferencePattern) return TEXT_PATTERN;
let pattern = textPatternsByForgeReference.get(forgeReferencePattern);
if (!pattern) {
pattern = createTextPattern(PROSE_PATH, forgeReferencePattern);
textPatternsByForgeReference.set(forgeReferencePattern, pattern);
}
return pattern;
}
function linkMetadata(inlineCodePaths: ReadonlySet<string>, forgeReferencePattern?: string) {
// The engine accepts regexes on both platforms. Classify complete code spans
// first, then pass literal matches rather than maintaining a second path grammar.
const alternatives = [...inlineCodePaths].map((path) =>
@@ -47,7 +61,7 @@ function linkMetadata(inlineCodePaths: ReadonlySet<string>) {
);
return {
linkRecognition: {
text: FILE_PATTERN,
text: textPattern(forgeReferencePattern),
inlineCode: new RegExp(
String.raw`^(?:https?://[^\s<>]+${alternatives.length ? `|${alternatives.join("|")}` : ""})$`,
),
@@ -120,7 +134,12 @@ function workspaceHoverTitle(link: Link, markup: string, workspaceRoot?: string)
export function prepareMarkdownSource(
source: string,
workspaceRoot?: string,
options: { supportsSvg?: boolean; commandNames?: ReadonlySet<string> } = {},
options: {
supportsSvg?: boolean;
commandNames?: ReadonlySet<string>;
/** Regex source for forge references such as `#12`; see `@/git/forge-shorthand`. */
forgeReferencePattern?: string;
} = {},
) {
source = normalizeMarkdownHtml(source);
const images: MarkdownImage[] = [];
@@ -132,6 +151,7 @@ export function prepareMarkdownSource(
}));
const metadata = linkMetadata(
collectInlineCodePaths(source, workspaceRoot, options.commandNames),
options.forgeReferencePattern,
);
const edits: { start: number; end: number; text: string }[] = [];
if (
+9 -2
View File
@@ -41,8 +41,15 @@ export interface ForgeReferencePath {
kind: "change_request" | "issue";
/** Path between the repository path and the numeric local id. */
infix: string;
/**
* Shorthand marker the forge links in prose, e.g. GitHub `#12`, GitLab `!12`.
* Absent when the forge has no shorthand for this kind.
*/
sigil?: ForgeReferenceSigil;
}
export type ForgeReferenceSigil = "#" | "!";
/** Line/range anchor shared by every GitHub-family forge (github, gitea, forgejo, codeberg). */
export function GITHUB_LINE_ANCHOR(start: number, end?: number): string {
return end && end > start ? `#L${start}-L${end}` : `#L${start}`;
@@ -54,8 +61,8 @@ export const GITEA_FAMILY_URL_GRAMMAR: ForgeUrlGrammar = {
blobInfix: "/src/branch/",
lineAnchor: GITHUB_LINE_ANCHOR,
referencePaths: [
{ kind: "change_request", infix: "/pulls/" },
{ kind: "issue", infix: "/issues/" },
{ kind: "change_request", infix: "/pulls/", sigil: "!" },
{ kind: "issue", infix: "/issues/", sigil: "#" },
],
};
@@ -0,0 +1,35 @@
import { createContext, useCallback, useContext, useMemo, type ReactNode } from "react";
import { createForgeShorthand, type ForgeShorthand } from "./shorthand";
export { createForgeShorthand, type ForgeShorthand } from "./shorthand";
const ForgeShorthandContext = createContext<ForgeShorthand | null>(null);
/** Makes `#12`-style references in descendant Markdown open on the workspace forge. */
export function ForgeShorthandProvider({
forge,
remoteUrl,
children,
}: {
forge: string | null | undefined;
remoteUrl: string | null | undefined;
children: ReactNode;
}) {
const value = useMemo(() => createForgeShorthand({ forge, remoteUrl }), [forge, remoteUrl]);
return <ForgeShorthandContext.Provider value={value}>{children}</ForgeShorthandContext.Provider>;
}
export function useForgeShorthand(): ForgeShorthand | null {
return useContext(ForgeShorthandContext);
}
/** Carries the shorthand environment across a portal. */
export function useForgeShorthandBridge() {
const value = useContext(ForgeShorthandContext);
return useCallback(
(children: ReactNode) => (
<ForgeShorthandContext.Provider value={value}>{children}</ForgeShorthandContext.Provider>
),
[value],
);
}
@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import { createForgeShorthand } from "./shorthand";
function matches(shorthand: { pattern: string }, text: string): string[] {
return [...text.matchAll(new RegExp(shorthand.pattern, "g"))].map((match) => match[0]);
}
describe("createForgeShorthand", () => {
const github = createForgeShorthand({
forge: "github",
remoteUrl: "git@github.com:getpaseo/paseo.git",
})!;
const gitlab = createForgeShorthand({
forge: "gitlab",
remoteUrl: "https://gitlab.com/g/p.git",
})!;
it("resolves same-repo and cross-repo references on the remote's host", () => {
expect(github.resolve("#8613")).toBe("https://github.com/getpaseo/paseo/issues/8613");
expect(github.resolve("acme/tools#4")).toBe("https://github.com/acme/tools/issues/4");
expect(gitlab.resolve("!7")).toBe("https://gitlab.com/g/p/-/merge_requests/7");
expect(gitlab.resolve("g/sub/q#3")).toBe("https://gitlab.com/g/sub/q/-/issues/3");
});
it("only recognizes the sigils the forge declares", () => {
expect(matches(github, "see #1 and !2")).toEqual(["#1"]);
expect(github.resolve("!2")).toBeNull();
expect(matches(gitlab, "see #1 and !2")).toEqual(["#1", "!2"]);
});
it("recognizes references only at word boundaries", () => {
expect(
matches(
github,
"(#1), #2. acme/tools#3, foo#4 #5abc &#6; https://x/#7 #0 #08 wow!9 user@host#10",
),
).toEqual(["#1", "#2", "acme/tools#3"]);
});
it("falls back to the cloud host forge when the daemon reports none", () => {
expect(
createForgeShorthand({ forge: undefined, remoteUrl: "git@github.com:a/b.git" })?.resolve(
"#1",
),
).toBe("https://github.com/a/b/issues/1");
});
it("is absent without a remote or a known forge", () => {
expect(createForgeShorthand({ forge: "github", remoteUrl: null })).toBeNull();
expect(
createForgeShorthand({ forge: undefined, remoteUrl: "git@git.corp:a/b.git" }),
).toBeNull();
});
});
@@ -0,0 +1,55 @@
import type { ForgeReferenceSigil } from "@/git/client-forge-module";
import { forgeFromRemoteUrl } from "@/git/forge";
import { getClientForgeLogicModule } from "@/git/forges";
import { buildForgeReferenceUrl } from "@/git/forge-url";
/**
* Shorthand references in prose, such as `#12`, `!12` or `owner/repo#12`,
* resolved against one workspace remote.
*/
export interface ForgeShorthand {
/** Regex source for recognizing references in Markdown text. */
pattern: string;
/** Web URL for a recognized reference, or null. */
resolve(text: string): string | null;
}
// Keep these in the syntax shared by JavaScript, ICU and java.util.regex: the
// pattern runs in the native Markdown engines too.
const REPO = String.raw`[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_.-]+)+`;
const NUMBER = String.raw`[1-9][0-9]{0,8}`;
// A reference never continues a word, path, URL fragment or entity.
const BEFORE = String.raw`(?<![A-Za-z0-9_./@#!&-])`;
const AFTER = String.raw`(?![A-Za-z0-9_])`;
const REFERENCE = new RegExp(String.raw`^(?:(${REPO}))?([#!])(${NUMBER})$`);
export function createForgeShorthand(input: {
forge: string | null | undefined;
remoteUrl: string | null | undefined;
}): ForgeShorthand | null {
const remoteUrl = input.remoteUrl;
const forge = input.forge || forgeFromRemoteUrl(remoteUrl);
if (!forge || !remoteUrl) return null;
const sigils = forgeSigils(forge).filter(
(sigil) => buildForgeReferenceUrl(forge, { remoteUrl, sigil, number: 1 }) !== null,
);
if (sigils.length === 0) return null;
return {
pattern: `${BEFORE}(?:${REPO})?[${sigils.join("")}]${NUMBER}${AFTER}`,
resolve(text) {
const match = REFERENCE.exec(text);
if (!match) return null;
return buildForgeReferenceUrl(forge, {
remoteUrl,
repo: match[1] ?? null,
sigil: match[2] as ForgeReferenceSigil,
number: Number(match[3]),
});
},
};
}
function forgeSigils(forge: string): ForgeReferenceSigil[] {
const paths = getClientForgeLogicModule(forge)?.urlGrammar?.referencePaths ?? [];
return paths.flatMap((path) => (path.sigil ? [path.sigil] : []));
}
+68
View File
@@ -3,9 +3,77 @@ import {
buildForgeBlobUrl,
buildForgeBranchTreeUrl,
buildForgeChecksUrl,
buildForgeReferenceUrl,
hasForgeWebUrls,
} from "./forge-url";
describe("buildForgeReferenceUrl", () => {
it.each([
["github", "git@github.com:acme/repo.git", "#", null, "https://github.com/acme/repo/issues/12"],
["github", "git@github.com:acme/repo.git", "!", null, null],
[
"github",
"https://github.com/acme/repo",
"#",
"other/tools",
"https://github.com/other/tools/issues/12",
],
["gitlab", "git@gitlab.com:g/sub/p.git", "#", null, "https://gitlab.com/g/sub/p/-/issues/12"],
[
"gitlab",
"git@gitlab.com:g/sub/p.git",
"!",
null,
"https://gitlab.com/g/sub/p/-/merge_requests/12",
],
[
"gitlab",
"https://gitlab.corp:8443/g/p.git",
"!",
"g/q",
"https://gitlab.corp:8443/g/q/-/merge_requests/12",
],
[
"codeberg",
"https://codeberg.org/acme/repo.git",
"!",
null,
"https://codeberg.org/acme/repo/pulls/12",
],
[
"gitea",
"http://forge.test:3000/team/repo.git",
"#",
null,
"http://forge.test:3000/team/repo/issues/12",
],
[
"github",
"http://github.com/acme/repo.git",
"#",
null,
"https://github.com/acme/repo/issues/12",
],
[
"forgejo",
"git@forge.example:acme/repo.git",
"#",
null,
"https://forge.example/acme/repo/issues/12",
],
["bitbucket", "git@bitbucket.org:acme/repo.git", "#", null, null],
["github", null, "#", null, null],
["github", "git@github.com:acme/repo.git", "#", "../escape", null],
] as const)("builds %s %s %s in %s", (forge, remoteUrl, sigil, repo, expected) => {
expect(buildForgeReferenceUrl(forge, { remoteUrl, repo, sigil, number: 12 })).toBe(expected);
});
it("rejects non-positive numbers", () => {
const remoteUrl = "git@github.com:acme/repo.git";
expect(buildForgeReferenceUrl("github", { remoteUrl, sigil: "#", number: 0 })).toBeNull();
});
});
describe("buildForgeChecksUrl", () => {
it.each([
[
+46 -6
View File
@@ -12,6 +12,7 @@
*/
import { getForgeDefinition } from "@getpaseo/protocol/forge-manifest";
import { normalizeHost, parseGitRemoteLocation } from "@getpaseo/protocol/git-remote";
import type { ForgeReferenceSigil } from "@/git/client-forge-module";
import { getClientForgeLogicModule } from "@/git/forges";
export interface ForgeBlobUrlInput {
@@ -27,6 +28,14 @@ export interface ForgeBranchTreeUrlInput {
branch: string | null | undefined;
}
export interface ForgeReferenceUrlInput {
remoteUrl: string | null | undefined;
/** Repository path of a cross-repository reference; the remote's repository when absent. */
repo?: string | null;
sigil: ForgeReferenceSigil;
number: number;
}
export function buildForgeChecksUrl(forge: string, changeRequestUrl: string): string | null {
const suffix = getClientForgeLogicModule(forge)?.urlGrammar?.changeRequestChecksSuffix;
if (!suffix) {
@@ -45,6 +54,8 @@ export function buildForgeChecksUrl(forge: string, changeRequestUrl: string): st
}
interface ForgeWebLocation {
/** "http" only for a self-hosted http:// remote; every other remote browses over https. */
scheme: "http" | "https";
host: string;
/** Non-default port for a self-hosted http(s) origin, or undefined. */
port?: string;
@@ -87,16 +98,18 @@ function resolveForgeWebLocation(
!isCloudHost && (location.transport === "http" || location.transport === "https")
? location.port
: undefined;
return { host: webHost, port, repo: location.path };
const scheme = !isCloudHost && location.transport === "http" ? "http" : "https";
return { scheme, host: webHost, port, repo: location.path };
}
function encodeBranch(branch: string): string {
return branch.split("/").map(encodeURIComponent).join("/");
}
/** Host, plus `:port` when the remote pins a non-default port. */
function forgeAuthority(location: ForgeWebLocation): string {
return location.port ? `${location.host}:${location.port}` : location.host;
/** Scheme and host, plus `:port` when the remote pins a non-default port. */
function forgeOrigin(location: ForgeWebLocation): string {
const authority = location.port ? `${location.host}:${location.port}` : location.host;
return `${location.scheme}://${authority}`;
}
function normalizeBlobPath(path: string | null | undefined): string | null {
@@ -131,7 +144,7 @@ export function buildForgeBranchTreeUrl(
if (!grammar || !location || !branch || branch === "HEAD") {
return null;
}
return `https://${forgeAuthority(location)}/${location.repo}${grammar.treeInfix}${encodeBranch(branch)}`;
return `${forgeOrigin(location)}/${location.repo}${grammar.treeInfix}${encodeBranch(branch)}`;
}
export function buildForgeBlobUrl(forge: string, input: ForgeBlobUrlInput): string | null {
@@ -143,13 +156,40 @@ export function buildForgeBlobUrl(forge: string, input: ForgeBlobUrlInput): stri
return null;
}
const encodedPath = filePath.split("/").map(encodeURIComponent).join("/");
let url = `https://${forgeAuthority(location)}/${location.repo}${grammar.blobInfix}${encodeBranch(branch)}/${encodedPath}`;
let url = `${forgeOrigin(location)}/${location.repo}${grammar.blobInfix}${encodeBranch(branch)}/${encodedPath}`;
if (input.lineStart && input.lineStart > 0) {
url += grammar.lineAnchor(input.lineStart, input.lineEnd);
}
return url;
}
/**
* Web URL of a shorthand reference such as `#12` or `group/project!12`. A
* cross-repository reference stays on the remote's host. Null when the forge
* has no shorthand for the sigil.
*/
export function buildForgeReferenceUrl(
forge: string,
input: ForgeReferenceUrlInput,
): string | null {
const referencePath = getClientForgeLogicModule(forge)?.urlGrammar?.referencePaths?.find(
(path) => path.sigil === input.sigil,
);
const location = resolveForgeWebLocation(forge, input.remoteUrl);
const repo = input.repo ?? location?.repo;
if (
!referencePath ||
!location ||
!repo ||
!isValidRepoPath(repo) ||
!Number.isSafeInteger(input.number) ||
input.number <= 0
) {
return null;
}
return `${forgeOrigin(location)}/${repo}${referencePath.infix}${input.number}`;
}
/** Whether the forge has web URL builders (i.e. a known URL grammar). */
export function hasForgeWebUrls(forge: string): boolean {
return getClientForgeLogicModule(forge)?.urlGrammar !== undefined;
+2 -1
View File
@@ -89,7 +89,8 @@ export const githubForgeLogic = {
changeRequestChecksSuffix: "/checks",
referencePaths: [
{ kind: "change_request", infix: "/pull/" },
{ kind: "issue", infix: "/issues/" },
// GitHub redirects /issues/N to /pull/N, so `#N` covers both kinds.
{ kind: "issue", infix: "/issues/", sigil: "#" },
],
},
facts: defineForgeFacts({
+2 -2
View File
@@ -166,8 +166,8 @@ export const gitlabForgeLogic = {
lineAnchor: gitlabLineAnchor,
changeRequestChecksSuffix: "/pipelines",
referencePaths: [
{ kind: "change_request", infix: "/-/merge_requests/" },
{ kind: "issue", infix: "/-/issues/" },
{ kind: "change_request", infix: "/-/merge_requests/", sigil: "!" },
{ kind: "issue", infix: "/-/issues/", sigil: "#" },
],
},
facts: defineForgeFacts({
@@ -199,6 +199,83 @@ async function connected() {
return { ...h, stop };
}
it("keeps a restored plugin screen loading until its catalog arrives", async () => {
const h = harness();
expect(h.registry.getCatalogStatus("host")).toBe("loading");
const pending = Promise.withResolvers<Catalog>();
class PendingClient extends CatalogClient {
override getPluginCatalog() {
this.fetches++;
return pending.promise;
}
}
const client = new PendingClient();
h.sync({ client });
expect(h.registry.getCatalogStatus("host")).toBe("loading");
expect(h.registry.getSnapshot()).toEqual([]);
pending.resolve(client.catalog);
await expect.poll(() => h.registry.getCatalogStatus("host")).toBe("ready");
expect(h.installed().id).toBe("reports");
expect(client.fetches).toBe(1);
});
it("settles an empty catalog without leaving missing plugin screens loading", async () => {
const h = harness();
h.client.catalog = [];
const snapshot = h.registry.getSnapshot();
const statuses: string[] = [];
cleanups.push(
h.registry.subscribe(() => {
statuses.push(h.registry.getCatalogStatus("host"));
}),
);
h.sync();
await expect.poll(() => h.registry.getCatalogStatus("host")).toBe("ready");
expect(h.registry.getSnapshot()).toBe(snapshot);
expect(statuses).toEqual(["loading", "ready"]);
});
it("settles a failed catalog request instead of loading indefinitely", async () => {
const h = harness();
class FailingClient extends CatalogClient {
override async getPluginCatalog(): Promise<Catalog> {
throw new Error("Catalog request failed");
}
}
h.sync({ client: new FailingClient() });
await expect.poll(() => h.registry.getCatalogStatus("host")).toBe("error");
expect(h.registry.getSnapshot()).toEqual([]);
});
it("ignores an old failed request after a new connection loads its catalog", async () => {
const h = harness();
const pending = Promise.withResolvers<Catalog>();
const started = Promise.withResolvers<void>();
class PendingClient extends CatalogClient {
override getPluginCatalog() {
started.resolve();
return pending.promise;
}
}
const stop = h.sync({ client: new PendingClient() });
await started.promise;
stop();
h.sync();
await expect.poll(() => h.registry.getCatalogStatus("host")).toBe("ready");
const plugin = h.installed();
pending.reject(new Error("Old connection failed"));
await pending.promise.catch(() => undefined);
await new Promise<void>((resolve) => setTimeout(resolve, 0));
expect(h.registry.getCatalogStatus("host")).toBe("ready");
expect(h.installed()).toBe(plugin);
});
function settingsQuery(plugin: InstalledPlugin) {
return {
queryKey: pluginSettingsKey("preferences"),
+14 -5
View File
@@ -9,7 +9,10 @@ interface CatalogSyncInput {
audio: Pick<AudioEngine, "play"> | null;
connected: boolean;
supported: boolean;
registry: Pick<PluginRegistry, "installCatalog" | "getSnapshot" | "removeHost">;
registry: Pick<
PluginRegistry,
"installCatalog" | "getSnapshot" | "removeHost" | "setCatalogStatus"
>;
}
export function syncPluginCatalog({
@@ -22,30 +25,36 @@ export function syncPluginCatalog({
}: CatalogSyncInput): () => void {
if (!supported || !audio) {
registry.removeHost(serverId);
registry.setCatalogStatus(serverId, "ready");
return () => {};
}
// A dropped socket reconnects on the same client, so installations keep rendering offline.
if (!connected) return () => {};
registry.setCatalogStatus(serverId, "loading");
const installationOptions = { client, audio };
let cancelled = false;
let refreshQueue = Promise.resolve();
function refresh(replacePluginId?: string) {
refreshQueue = refreshQueue.then(() =>
client
refreshQueue = refreshQueue.then(() => {
if (cancelled) return undefined;
registry.setCatalogStatus(serverId, "loading");
return client
.getPluginCatalog()
.then((catalog) => {
if (!cancelled) {
registry.installCatalog(serverId, catalog, { ...installationOptions, replacePluginId });
registry.setCatalogStatus(serverId, "ready");
}
return undefined;
})
.catch((error) => {
if (!cancelled) {
registry.setCatalogStatus(serverId, "error");
console.warn(`[Plugins] Failed to load catalog for ${serverId}`, error);
}
return undefined;
}),
);
});
});
return refreshQueue;
}
const observation = client.observeEvents([
+27 -1
View File
@@ -13,6 +13,7 @@ import { runPluginClientBundle } from "./evaluate";
import type { InstalledPlugin } from "./types";
type CatalogPlugin = Awaited<ReturnType<DaemonClient["getPluginCatalog"]>>[number];
export type PluginCatalogStatus = "loading" | "ready" | "error";
export class PluginRegistry {
private readonly byHost = new Map<string, InstalledPlugin[]>();
@@ -20,6 +21,7 @@ export class PluginRegistry {
private snapshot: InstalledPlugin[] = [];
private readonly disposed = new WeakSet<InstalledPlugin>();
private readonly evaluationErrors = new Map<string, string>();
private readonly catalogStatuses = new Map<string, PluginCatalogStatus>();
/** The client each host's installations were bound to; their Paseo API and RPCs call it. */
private readonly clientByHost = new Map<string, DaemonClient>();
@@ -42,6 +44,16 @@ export class PluginRegistry {
return this.evaluationErrors.get(`${serverId}/${pluginId}`);
}
getCatalogStatus(serverId: string): PluginCatalogStatus {
return this.catalogStatuses.get(serverId) ?? "loading";
}
setCatalogStatus(serverId: string, status: PluginCatalogStatus): void {
if (this.catalogStatuses.get(serverId) === status) return;
this.catalogStatuses.set(serverId, status);
for (const listener of this.listeners) listener();
}
installCatalog(
serverId: string,
catalog: CatalogPlugin[],
@@ -177,8 +189,14 @@ export class PluginRegistry {
}
removeHost(serverId: string): void {
const hadCatalog = this.catalogStatuses.delete(serverId);
const installed = this.byHost.get(serverId);
if (!installed) return;
if (!installed) {
if (hadCatalog) {
for (const listener of this.listeners) listener();
}
return;
}
for (const plugin of installed) this.dispose(plugin);
for (const key of this.evaluationErrors.keys()) {
if (key.startsWith(`${serverId}/`)) this.evaluationErrors.delete(key);
@@ -253,6 +271,14 @@ export function useInstalledPlugin(serverId: string, pluginId: string): Installe
);
}
export function usePluginCatalogStatus(serverId: string): PluginCatalogStatus {
return useSyncExternalStore(
pluginRegistry.subscribe,
() => pluginRegistry.getCatalogStatus(serverId),
() => pluginRegistry.getCatalogStatus(serverId),
);
}
export function usePluginInstallations(pluginId: string): InstalledPlugin[] {
const installed = useInstalledPlugins();
return useMemo(() => installed.filter((plugin) => plugin.id === pluginId), [installed, pluginId]);
@@ -8,8 +8,25 @@ import {
parsePluginOpenScreenInput,
resolvePluginScreenTitle,
resolvePluginSurfaceContribution,
resolvePluginSurfacePlaceholder,
} from "./surface-contribution";
it("shows loading instead of unavailable while the host catalog is pending", () => {
expect(
resolvePluginSurfacePlaceholder({ connectionStatus: "online", catalogStatus: "loading" }),
).toBe("loading");
});
it.each([
["online", "ready", "unavailable"],
["online", "error", "error"],
["connecting", "ready", "loading"],
["offline", "loading", "offline"],
["error", "loading", "offline"],
] as const)("shows %s host with %s catalog as %s", (connectionStatus, catalogStatus, expected) => {
expect(resolvePluginSurfacePlaceholder({ connectionStatus, catalogStatus })).toBe(expected);
});
function installation(
serverId: string,
surfaces: string[],
@@ -5,6 +5,26 @@ import {
pluginScreenParamsFromRoute,
} from "./routes";
import type { InstalledPlugin } from "./types";
import type { PluginCatalogStatus } from "./registry";
import type { HostRuntimeConnectionStatus } from "@/runtime/host-runtime";
export type PluginSurfacePlaceholderStatus = "loading" | "offline" | "error" | "unavailable";
interface PluginSurfacePlaceholderInput {
connectionStatus: HostRuntimeConnectionStatus;
catalogStatus: PluginCatalogStatus;
}
export function resolvePluginSurfacePlaceholder({
connectionStatus,
catalogStatus,
}: PluginSurfacePlaceholderInput): PluginSurfacePlaceholderStatus {
if (connectionStatus === "offline" || connectionStatus === "error") return "offline";
if (connectionStatus !== "online") return "loading";
if (catalogStatus === "loading") return "loading";
if (catalogStatus === "error") return "error";
return "unavailable";
}
export type PluginSurfaceContributionIdentity =
| { kind: "sidebar"; id: string }
+40 -5
View File
@@ -12,14 +12,19 @@ import { ScreenHeader } from "@/components/headers/screen-header";
import { ScreenTitle } from "@/components/headers/screen-title";
import { HostFilter } from "@/components/hosts/host-filter";
import type { HostPickerHost } from "@/components/hosts/host-picker";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useHostRuntimeClient, useHosts } from "@/runtime/host-runtime";
import {
useHostRuntimeClient,
useHostRuntimeConnectionStatus,
useHosts,
} from "@/runtime/host-runtime";
import type { Theme } from "@/styles/theme";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { usePluginHostNavigation } from "./host-navigation";
import { PluginIconView } from "./icons";
import { toPluginTheme } from "./theme";
import { useInstalledPlugin, usePluginInstallations } from "./registry";
import { useInstalledPlugin, usePluginCatalogStatus, usePluginInstallations } from "./registry";
import { buildPluginSurfaceRoute, pluginScreenParamsFromRoute } from "./routes";
import {
legacySidebarItemHostKey,
@@ -32,7 +37,9 @@ import {
getPluginSurfaceContributionServerIds,
resolvePluginScreenTitle,
resolvePluginSurfaceContribution,
resolvePluginSurfacePlaceholder,
type PluginSurfaceContributionIdentity,
type PluginSurfacePlaceholderStatus,
} from "./surface-contribution";
import { resolvePluginPlatform } from "./platform";
@@ -42,6 +49,28 @@ const pluginThemeMapping = (theme: Theme) => ({
theme: toPluginTheme(theme),
});
const ThemedX = withUnistyles(X);
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const SURFACE_ERRORS = {
offline: "Plugin host is offline.",
error: "Could not load plugins. Reconnect to try again.",
unavailable: "This plugin surface is unavailable.",
} as const;
function PluginSurfacePlaceholder({ status }: { status: PluginSurfacePlaceholderStatus }) {
if (status === "loading") {
return (
<View
testID="plugin-surface-loading"
accessibilityRole="progressbar"
accessibilityLabel="Loading plugin"
style={styles.loading}
>
<ThemedLoadingSpinner size="large" uniProps={mutedColorMapping} />
</View>
);
}
return <Text style={styles.errorText}>{SURFACE_ERRORS[status]}</Text>;
}
function routeParam(value: string | string[] | undefined): string {
return typeof value === "string" ? value : "";
@@ -137,6 +166,9 @@ export function PluginSurfaceScreen() {
const installations = usePluginInstallations(pluginId);
const hosts = useHosts();
const client = useHostRuntimeClient(serverId);
const connectionStatus = useHostRuntimeConnectionStatus(serverId);
const catalogStatus = usePluginCatalogStatus(serverId);
const placeholderStatus = resolvePluginSurfacePlaceholder({ connectionStatus, catalogStatus });
const compact = useIsCompactFormFactor();
const { sidebarItem, surface } = useMemo(
() => resolvePluginSurfaceContribution(plugin, identity),
@@ -239,9 +271,7 @@ export function PluginSurfaceScreen() {
/>
</SurfaceErrorBoundary>
) : (
<Text style={styles.errorText}>
{plugin && surface ? "Plugin host is offline." : "This plugin surface is unavailable."}
</Text>
<PluginSurfacePlaceholder status={placeholderStatus} />
)}
</View>
</View>
@@ -256,6 +286,11 @@ const styles = StyleSheet.create((theme) => ({
body: {
flex: 1,
},
loading: {
flex: 1,
alignItems: "center",
justifyContent: "center",
},
errorText: {
color: theme.colors.statusDanger,
padding: theme.spacing[4],
@@ -2,6 +2,7 @@ import React, { createContext, useCallback, useContext, type ReactNode } from "r
import invariant from "tiny-invariant";
import { NavigationContext, NavigationRouteContext } from "expo-router/react-navigation";
import { useAssistantFileLinkEnvironmentBridge } from "@/assistant-file-links";
import { useForgeShorthandBridge } from "@/git/forge-shorthand";
import { useComposerEditingEnvironmentBridge } from "@/composer/editing-session";
import { RetainedPanelActivity, useRetainedPanelActive } from "@/components/retained-panel";
import type { ContextBridge } from "@/components/ui/isolated-bottom-sheet-modal";
@@ -56,13 +57,14 @@ export function useSheetPaneBridge(): ContextBridge {
const editing = useComposerEditingEnvironmentBridge();
const cache = useDiffDocumentWorkspaceCacheBridge();
const files = useAssistantFileLinkEnvironmentBridge();
const forgeShorthand = useForgeShorthandBridge();
const active = useRetainedPanelActive();
const serverId = useHostRouteServerId();
const navigation = useContext(NavigationContext);
const route = useContext(NavigationRouteContext);
return useCallback(
(children) => {
const content = editing(cache(files(children)));
const content = editing(cache(files(forgeShorthand(children))));
return (
<SheetPaneHostContext.Provider value={host}>
<NavigationContext.Provider value={navigation}>
@@ -79,6 +81,6 @@ export function useSheetPaneBridge(): ContextBridge {
</SheetPaneHostContext.Provider>
);
},
[host, editing, cache, files, active, serverId, navigation, route],
[host, editing, cache, files, forgeShorthand, active, serverId, navigation, route],
);
}
@@ -3,12 +3,14 @@ import { useHostFeature } from "@/runtime/host-features";
import {
AssistantFileLinkResolverProvider,
createInlinePathOpener,
type AssistantFileLinkResolverProviderProps,
type InlinePathTarget,
} from "@/assistant-file-links";
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
import { revealWorkspaceTab } from "@/workspace-tabs/navigation";
import type { LayoutChangeEvent } from "react-native";
import { ExplorerSidebarDock } from "@/screens/workspace/explorer-sidebar";
import { ForgeShorthandProvider } from "@/git/forge-shorthand";
import { ExplorerFrame } from "./explorer-sidebar/frame";
import { WorkspaceTabDragScope } from "@/components/workspace-tab-drag-scope";
import {
@@ -3778,7 +3780,8 @@ function WorkspaceScreenContent({
workspaceKey={persistenceKey}
sheetPaneHost={sheetPaneHost}
>
<AssistantFileLinkResolverProvider
<WorkspaceMarkdownLinks
workspace={workspaceDescriptor}
client={client}
serverId={normalizedServerId}
workspaceRoot={workspaceDirectory ?? undefined}
@@ -3786,11 +3789,26 @@ function WorkspaceScreenContent({
toast={toast}
>
{renderedWorkspaceScreen}
</AssistantFileLinkResolverProvider>
</WorkspaceMarkdownLinks>
</WorkspaceContentProviders>
);
}
/** File links and forge references for every Markdown surface in the workspace. */
function WorkspaceMarkdownLinks({
workspace,
children,
...fileLinks
}: AssistantFileLinkResolverProviderProps & { workspace: WorkspaceDescriptor | null | undefined }) {
return (
<AssistantFileLinkResolverProvider {...fileLinks}>
<ForgeShorthandProvider forge={workspace?.forge} remoteUrl={workspace?.gitRuntime?.remoteUrl}>
{children}
</ForgeShorthandProvider>
</AssistantFileLinkResolverProvider>
);
}
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
@@ -21,6 +21,36 @@ describe("tool-call-parsers", () => {
expect(parsed.find((entry) => entry.type === "add")?.content).toBe("+new");
});
it("keeps file headers in multi-file diffs", () => {
const parsed = parseUnifiedDiff(
[
"diff --git a/alpha.txt b/alpha.txt",
"--- a/alpha.txt",
"+++ b/alpha.txt",
"@@ -1 +1 @@",
"-color = blue",
"+color = green",
"diff --git a/beta.txt b/beta.txt",
"--- a/beta.txt",
"+++ b/beta.txt",
"@@ -1 +1 @@",
"-size = small",
"+size = large",
].join("\n"),
);
expect(parsed).toEqual([
{ type: "header", content: "diff --git a/alpha.txt b/alpha.txt" },
{ type: "header", content: "@@ -1 +1 @@" },
{ type: "remove", content: "-color = blue" },
{ type: "add", content: "+color = green" },
{ type: "header", content: "diff --git a/beta.txt b/beta.txt" },
{ type: "header", content: "@@ -1 +1 @@" },
{ type: "remove", content: "-size = small" },
{ type: "add", content: "+size = large" },
]);
});
it("extracts TodoWrite task entries", () => {
const tasks = extractTaskEntriesFromToolCall("TodoWrite", {
todos: [
+4 -1
View File
@@ -210,6 +210,8 @@ export function parseUnifiedDiff(diffText?: string): DiffLine[] {
}
const lines = splitIntoLines(diffText);
const fileHeaders = lines.filter((line) => line.startsWith("diff --git "));
const hasMultipleFiles = fileHeaders.length > 1;
const diff: DiffLine[] = [];
for (const line of lines) {
@@ -218,7 +220,8 @@ export function parseUnifiedDiff(diffText?: string): DiffLine[] {
continue;
}
if (line.startsWith("@@")) {
const isFileHeader = hasMultipleFiles && line.startsWith("diff --git ");
if (line.startsWith("@@") || isFileHeader) {
diff.push({ type: "header", content: line });
continue;
}
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.15.0-beta.1",
"version": "0.15.0",
"description": "Paseo CLI - control your AI coding agents from the command line",
"bin": {
"paseo": "bin/paseo"
@@ -28,9 +28,9 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.15.0-beta.1",
"@getpaseo/protocol": "0.15.0-beta.1",
"@getpaseo/server": "0.15.0-beta.1",
"@getpaseo/client": "0.15.0",
"@getpaseo/protocol": "0.15.0",
"@getpaseo/server": "0.15.0",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/client",
"version": "0.15.0-beta.1",
"version": "0.15.0",
"description": "Paseo client SDK package",
"files": [
"dist",
@@ -47,8 +47,8 @@
"test": "vitest run"
},
"dependencies": {
"@getpaseo/protocol": "0.15.0-beta.1",
"@getpaseo/relay": "0.15.0-beta.1",
"@getpaseo/protocol": "0.15.0",
"@getpaseo/relay": "0.15.0",
"ws": "^8.20.0",
"zod": "^4.4.3"
},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.15.0-beta.1",
"version": "0.15.0",
"private": true,
"description": "Paseo desktop app (Electron wrapper)",
"homepage": "https://paseo.sh",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/expo-two-way-audio",
"version": "0.15.0-beta.1",
"version": "0.15.0",
"description": "Native module for two way audio streaming",
"keywords": [
"ExpoTwoWayAudio",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.15.0-beta.1",
"version": "0.15.0",
"files": [
"dist",
"!dist/**/*.map",
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/plugin",
"version": "0.15.0-beta.1",
"version": "0.15.0",
"description": "Paseo plugin SDK package",
"files": [
"dist",
@@ -77,8 +77,8 @@
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.15.0-beta.1",
"@getpaseo/protocol": "0.15.0-beta.1",
"@getpaseo/client": "0.15.0",
"@getpaseo/protocol": "0.15.0",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.2.3",
@@ -88,8 +88,8 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.15.0-beta.1",
"@getpaseo/protocol": "0.15.0-beta.1",
"@getpaseo/client": "0.15.0",
"@getpaseo/protocol": "0.15.0",
"react": ">=19.1.0 <20",
"react-native": ">=0.81.5",
"zod": "^4.4.3"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/protocol",
"version": "0.15.0-beta.1",
"version": "0.15.0",
"description": "Paseo shared protocol schemas and wire types",
"files": [
"dist",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.15.0-beta.1",
"version": "0.15.0",
"description": "Paseo relay for bridging daemon and client connections",
"files": [
"dist",
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.15.0-beta.1",
"version": "0.15.0",
"description": "Paseo backend server",
"files": [
"dist/server",
@@ -107,11 +107,11 @@
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.15.0-beta.1",
"@getpaseo/highlight": "0.15.0-beta.1",
"@getpaseo/plugin": "0.15.0-beta.1",
"@getpaseo/protocol": "0.15.0-beta.1",
"@getpaseo/relay": "0.15.0-beta.1",
"@getpaseo/client": "0.15.0",
"@getpaseo/highlight": "0.15.0",
"@getpaseo/plugin": "0.15.0",
"@getpaseo/protocol": "0.15.0",
"@getpaseo/relay": "0.15.0",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
@@ -3436,6 +3436,87 @@ describe("Codex app-server provider", () => {
}
});
test.each([
["patch_apply_begin", undefined, "running", null],
["patch_apply_end", true, "completed", null],
["patch_apply_end", false, "failed", { message: "Cannot apply patch" }],
] as const)(
"maps both files from legacy %s notifications (success: %s)",
async (type, success, status, error) => {
const appServer = createFakeCodexAppServer();
const session = new CodexAppServerAgentSession(
createConfig({ cwd: "/tmp/repo" }),
null,
createTestLogger(),
async () => appServer.child,
);
try {
await session.connect();
const nextTimelineItem = waitForNextTimelineItem(session);
appServer.child.stdout.write(
`${JSON.stringify({
method: `codex/event/${type}`,
params: {
conversationId: "thread-1",
msg: {
type,
call_id: "patch-array-alias",
changes: [
{
path: "/tmp/repo/src/array-alias.ts",
kind: "modify",
unified_diff: "@@\n-old\n+new\n",
},
{
path: "/tmp/repo/src/second.ts",
kind: "modify",
unified_diff: "@@\n-before\n+after\n",
},
],
success,
stderr: "Cannot apply patch",
},
},
})}\n`,
);
await expect(nextTimelineItem).resolves.toEqual({
type: "timeline",
provider: "codex",
item: {
type: "tool_call",
callId: "patch-array-alias",
name: "apply_patch",
status,
error,
detail: {
type: "edit",
filePath: "src/array-alias.ts",
unifiedDiff: [
"diff --git a/src/array-alias.ts b/src/array-alias.ts",
"--- a/src/array-alias.ts",
"+++ b/src/array-alias.ts",
"@@",
"-old",
"+new",
"",
"diff --git a/src/second.ts b/src/second.ts",
"--- a/src/second.ts",
"+++ b/src/second.ts",
"@@",
"-before",
"+after",
].join("\n"),
},
},
});
appServer.assertNoErrors();
} finally {
await session.close();
}
},
);
test("maps Codex plan markdown to a synthetic plan tool call", () => {
const item = mapCodexPlanToToolCall({
callId: "plan-turn-1",
@@ -7879,3 +7960,93 @@ test("publishes a materialized image before dispatching the following turn compl
await session.close();
}
});
describe("legacy multi-file diff budget", () => {
test.each([true, false])(
"retains later file diffs when a large patch is running: %s",
(running) => {
const item = mapCodexPatchNotificationToToolCall({
callId: "large-legacy-patch",
cwd: "/repo",
running,
success: true,
changes: [
{
path: "/repo/alpha.txt",
kind: "modify",
unified_diff: "@@ -1 +1 @@\n-old\n+" + "a".repeat(14000),
},
{ path: "/repo/beta.txt", kind: "modify", unified_diff: "@@ -1 +1 @@\n-small\n+large\n" },
],
});
expect(item?.detail.type).toBe("edit");
if (item?.detail.type !== "edit") throw new Error("Expected edit detail");
expect(item.detail.unifiedDiff).toContain("diff --git a/beta.txt b/beta.txt");
expect(item.detail.unifiedDiff).toContain("+large");
expect(item.detail.unifiedDiff!.length).toBeLessThanOrEqual(12000);
},
);
});
describe("legacy multi-file move metadata", () => {
test.each([true, false])("preserves path-keyed moves while running: %s", (running) => {
const item = mapCodexPatchNotificationToToolCall({
callId: "legacy-move",
cwd: "/repo",
running,
success: true,
changes: {
"/repo/old.txt": {
type: "update",
move_path: "/repo/new.txt",
unified_diff: "@@ -1 +1 @@\n-old\n+new\n",
},
"/repo/other.txt": { type: "update", unified_diff: "@@ -1 +1 @@\n-before\n+after\n" },
},
});
if (item?.detail.type !== "edit") throw new Error("Expected edit");
expect(item.detail.unifiedDiff).toContain("diff --git a/old.txt b/new.txt");
expect(item.detail.unifiedDiff).toContain("+++ b/new.txt");
});
});
describe("legacy move-only entries", () => {
test.each([true, false])(
"keeps a move-only file alongside another edit while running: %s",
(running) => {
const item = mapCodexPatchNotificationToToolCall({
callId: "legacy-move-only",
cwd: "/repo",
running,
success: true,
changes: {
"/repo/old.txt": { type: "update", move_path: "/repo/new.txt", unified_diff: "" },
"/repo/second.txt": { type: "update", unified_diff: "@@ -1 +1 @@\n-small\n+large\n" },
},
});
if (item?.detail.type !== "edit") throw new Error("Expected edit");
expect(item.detail.unifiedDiff).toContain("rename from old.txt\nrename to new.txt");
expect(item.detail.unifiedDiff).toContain("diff --git a/second.txt b/second.txt");
},
);
});
describe("legacy empty additions", () => {
test.each([true, false])("keeps an empty new file while running: %s", (running) => {
const item = mapCodexPatchNotificationToToolCall({
callId: "legacy-empty",
cwd: "/repo",
running,
success: true,
changes: {
"/repo/empty.txt": { type: "add", content: "" },
"/repo/second.txt": { type: "update", unified_diff: "@@ -1 +1 @@\n-old\n+new\n" },
},
});
if (item?.detail.type !== "edit") throw new Error("Expected edit");
expect(item.detail.unifiedDiff).toContain(
"diff --git a/empty.txt b/empty.txt\n--- /dev/null\n+++ b/empty.txt",
);
expect(item.detail.unifiedDiff).toContain("diff --git a/second.txt b/second.txt");
});
});
@@ -55,11 +55,13 @@ import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
import { composeSystemPromptParts } from "../system-prompt.js";
import { CodexAsyncQuestions, codexAsyncQuestionToTimeline } from "./codex/async-questions.js";
import {
fileChangesToPreview,
mapCodexToolCallEnvelope,
mapCodexToolCallFromThreadItem,
normalizeCommandExecutionCommand,
splitCodexMcpToolResultImages,
} from "./codex/tool-call-mapper.js";
import { normalizeCodexFilePath } from "./codex/tool-call-detail-parser.js";
import {
checkProviderLaunchAvailable,
createProviderEnv,
@@ -1402,6 +1404,15 @@ interface CodexPatchFileChange {
path: string;
kind?: string;
content?: string;
movePath?: string;
}
function codexPatchMovePath(value: unknown): string | undefined {
const record = toObjectRecord(value);
if (!record) return undefined;
const kind = toObjectRecord(record.kind);
const destination = record.move_path ?? kind?.move_path;
return typeof destination === "string" ? destination : undefined;
}
function extractPatchLikeText(value: unknown): string | undefined {
@@ -1534,6 +1545,7 @@ function parseCodexPatchChanges(changes: unknown): CodexPatchFileChange[] {
(typeof record.type === "string" && record.type) ||
undefined,
content: extractPatchLikeText(record),
movePath: codexPatchMovePath(record),
};
})
.filter((entry): entry is CodexPatchFileChange => entry !== null);
@@ -1553,6 +1565,7 @@ function parseCodexPatchChanges(changes: unknown): CodexPatchFileChange[] {
(typeof recordChanges.type === "string" && recordChanges.type) ||
undefined,
content: extractPatchLikeText(recordChanges),
movePath: codexPatchMovePath(recordChanges),
},
];
}
@@ -1572,6 +1585,7 @@ function parseCodexPatchChanges(changes: unknown): CodexPatchFileChange[] {
? ((value as { type?: string }).type ?? undefined)
: undefined,
content: extractPatchLikeText(value),
movePath: codexPatchMovePath(value),
};
})
.filter((entry): entry is CodexPatchFileChange => entry !== null);
@@ -1680,6 +1694,31 @@ function mapCodexExecNotificationToToolCall(params: {
return params.running ? toRunningToolCall(mapped) : mapped;
}
function codexPatchPreview(
files: CodexPatchFileChange[],
cwd?: string | null,
): {
filePath: string | undefined;
patchText: string | undefined;
} {
const preview =
files.length > 1
? fileChangesToPreview(
files.map((file) => ({
path: normalizeCodexFilePath(file.path, cwd) ?? file.path,
kind: file.kind,
diff: file.content,
movePath: file.movePath ? normalizeCodexFilePath(file.movePath, cwd) : undefined,
})),
)
: undefined;
const firstPath = preview?.filePath ?? files[0]?.path;
const firstPatchText = files
.map((file) => file.content?.trim())
.find((value): value is string => typeof value === "string" && value.length > 0);
return { filePath: firstPath, patchText: preview?.unifiedDiff ?? firstPatchText };
}
export function mapCodexPatchNotificationToToolCall(params: {
callId?: string | null;
changes: unknown;
@@ -1690,11 +1729,7 @@ export function mapCodexPatchNotificationToToolCall(params: {
running: boolean;
}): ToolCallTimelineItem | null {
const files = parseCodexPatchChanges(params.changes);
const firstPath = files[0]?.path;
const firstPatchText = files
.map((file) => file.content?.trim())
.find((value): value is string => typeof value === "string" && value.length > 0);
const patchText = firstPatchText;
const { filePath: firstPath, patchText } = codexPatchPreview(files, params.cwd);
const patchFields = codexPatchTextFields(patchText);
const mapped = mapCodexToolCallEnvelope({
callId: params.callId ?? null,
@@ -528,6 +528,165 @@ describe("codex tool-call mapper", () => {
});
});
it.each([
["inProgress", "running"],
["completed", "completed"],
])("keeps both fileChange diffs when the patch is %s", (status, expectedStatus) => {
const item = mapCodexToolCallFromThreadItem(
{
type: "fileChange",
id: "codex-two-files",
status,
changes: [
{
path: "/tmp/repo/alpha.txt",
kind: { type: "update", move_path: null },
diff: "@@ -1 +1 @@\n-color = blue\n+color = green\n",
},
{
path: "/tmp/repo/beta.txt",
kind: { type: "update", move_path: null },
diff: "@@ -1 +1 @@\n-size = small\n+size = large\n",
},
],
},
{ cwd: "/tmp/repo" },
);
expect(item).toEqual({
type: "tool_call",
callId: "codex-two-files",
name: "apply_patch",
status: expectedStatus,
error: null,
detail: {
type: "edit",
filePath: "alpha.txt",
unifiedDiff: [
"diff --git a/alpha.txt b/alpha.txt",
"--- a/alpha.txt",
"+++ b/alpha.txt",
"@@ -1 +1 @@",
"-color = blue",
"+color = green",
"",
"diff --git a/beta.txt b/beta.txt",
"--- a/beta.txt",
"+++ b/beta.txt",
"@@ -1 +1 @@",
"-size = small",
"+size = large",
].join("\n"),
},
});
});
it("uses the move destination in generated multi-file diff headers", () => {
const item = mapCodexToolCallFromThreadItem(
{
type: "fileChange",
id: "codex-moved-file",
status: "completed",
changes: [
{
path: "/tmp/repo/old.txt",
kind: { type: "update", move_path: "/tmp/repo/new.txt" },
diff: "@@ -1 +1 @@\n-old\n+new\n",
},
{
path: "/tmp/repo/other.txt",
kind: { type: "update", move_path: null },
diff: "@@ -1 +1 @@\n-before\n+after\n",
},
],
},
{ cwd: "/tmp/repo" },
);
expect(item).toEqual({
type: "tool_call",
callId: "codex-moved-file",
name: "apply_patch",
status: "completed",
error: null,
detail: {
type: "edit",
filePath: "old.txt",
unifiedDiff: [
"diff --git a/old.txt b/new.txt",
"--- a/old.txt",
"+++ b/new.txt",
"@@ -1 +1 @@",
"-old",
"+new",
"",
"diff --git a/other.txt b/other.txt",
"--- a/other.txt",
"+++ b/other.txt",
"@@ -1 +1 @@",
"-before",
"+after",
].join("\n"),
},
});
});
it("keeps update, add, and delete changes in one fileChange diff", () => {
const item = mapCodexToolCallFromThreadItem(
{
type: "fileChange",
id: "codex-mixed-files",
status: "completed",
changes: [
{
path: "/tmp/repo/alpha.txt",
kind: { type: "update", move_path: null },
diff: "--- a/alpha.txt\n+++ b/alpha.txt\n@@ -1 +1 @@\n-blue\n+green\n",
},
{ path: "/tmp/repo/new.txt", kind: { type: "add" }, diff: "new\n\nvalue\n" },
{ path: "/tmp/repo/old.txt", kind: { type: "delete" }, diff: "old\n\nvalue\n" },
],
},
{ cwd: "/tmp/repo" },
);
expect(item).toEqual({
type: "tool_call",
callId: "codex-mixed-files",
name: "apply_patch",
status: "completed",
error: null,
detail: {
type: "edit",
filePath: "alpha.txt",
unifiedDiff: [
"diff --git a/alpha.txt b/alpha.txt",
"--- a/alpha.txt",
"+++ b/alpha.txt",
"@@ -1 +1 @@",
"-blue",
"+green",
"",
"diff --git a/new.txt b/new.txt",
"--- /dev/null",
"+++ b/new.txt",
"@@ -0,0 +1,3 @@",
"+new",
"+",
"+value",
"",
"diff --git a/old.txt b/old.txt",
"--- a/old.txt",
"+++ /dev/null",
"@@ -1,3 +0,0 @@",
"-old",
"-",
"-value",
].join("\n"),
},
});
});
it("maps fileChange object-style change payloads keyed by path", () => {
const item = expectMapped(
mapCodexToolCallFromThreadItem(
@@ -827,6 +986,44 @@ describe("codex tool-call mapper", () => {
});
});
it("maps multi-file path-only updates to unknown detail", () => {
const item = mapCodexToolCallFromThreadItem(
{
type: "fileChange",
id: "codex-multi-file-path-only",
status: "completed",
changes: [
{ path: "/tmp/repo/first.ts", kind: "modify" },
{ path: "/tmp/repo/second.ts", kind: { type: "update", move_path: null } },
],
},
{ cwd: "/tmp/repo" },
);
expect(item).toEqual({
type: "tool_call",
callId: "codex-multi-file-path-only",
name: "apply_patch",
status: "completed",
error: null,
detail: {
type: "unknown",
input: {
files: [
{ path: "first.ts", kind: "modify" },
{ path: "second.ts", kind: "update" },
],
},
output: {
files: [
{ path: "first.ts", kind: "modify" },
{ path: "second.ts", kind: "update" },
],
},
},
});
});
it("maps path-only apply_patch tool-call payloads to unknown detail instead of empty edit detail", () => {
const item = expectMapped(
mapCodexToolCallEnvelope({
@@ -1102,3 +1299,186 @@ describe("codex tool-call mapper", () => {
`);
});
});
describe("multi-file diff budget", () => {
it("retains a labeled diff for later files when the first diff exceeds the detail limit", () => {
const item = mapCodexToolCallFromThreadItem(
{
type: "fileChange",
id: "large-multi-file",
status: "completed",
changes: [
{
path: "/repo/alpha.txt",
kind: "modify",
diff: "@@ -1 +1 @@\n-old\n+" + "a".repeat(14000),
},
{ path: "/repo/beta.txt", kind: "modify", diff: "@@ -1 +1 @@\n-small\n+large\n" },
],
},
{ cwd: "/repo" },
);
expect(item?.detail.type).toBe("edit");
if (item?.detail.type !== "edit") throw new Error("Expected edit detail");
expect(item.detail.unifiedDiff).toContain("diff --git a/alpha.txt b/alpha.txt");
expect(item.detail.unifiedDiff).toContain("diff --git a/beta.txt b/beta.txt");
expect(item.detail.unifiedDiff).toContain("+large");
expect(item.detail.unifiedDiff!.length).toBeLessThanOrEqual(12000);
});
});
describe("multi-file preview metadata", () => {
it("preserves a path-keyed move destination", () => {
const item = mapCodexToolCallFromThreadItem(
{
type: "fileChange",
id: "moved-keyed",
status: "completed",
changes: {
"/repo/old.txt": {
type: "update",
move_path: "/repo/new.txt",
unified_diff: "@@ -1 +1 @@\n-old\n+new\n",
},
"/repo/other.txt": { type: "update", unified_diff: "@@ -1 +1 @@\n-before\n+after\n" },
},
},
{ cwd: "/repo" },
);
expect(item?.detail.type).toBe("edit");
if (item?.detail.type !== "edit") throw new Error("Expected edit");
expect(item.detail.unifiedDiff).toContain("diff --git a/old.txt b/new.txt");
expect(item.detail.unifiedDiff).toContain("+++ b/new.txt");
});
it("names the file whose diff is shown when the first entry has no content", () => {
const item = mapCodexToolCallFromThreadItem(
{
type: "fileChange",
id: "partial-content",
status: "completed",
changes: [
{ path: "/repo/first.txt", kind: "modify" },
{ path: "/repo/second.txt", kind: "modify", diff: "@@ -1 +1 @@\n-old\n+new\n" },
],
},
{ cwd: "/repo" },
);
expect(item?.detail).toMatchObject({ type: "edit", filePath: "second.txt" });
});
it("keeps whole labels and marks omitted files when many long paths exceed the preview", () => {
const paths = Array.from(
{ length: 50 },
(_, index) => `src/${String(index).padStart(2, "0")}-${"x".repeat(108)}.txt`,
);
const item = mapCodexToolCallFromThreadItem({
type: "fileChange",
id: "many-files",
status: "completed",
changes: paths.map((path) => ({ path, kind: "modify", diff: "@@ -1 +1 @@\n-old\n+new\n" })),
});
if (item?.detail.type !== "edit") throw new Error("Expected edit");
const diff = item.detail.unifiedDiff!;
const headers = diff.split("\n").filter((line) => line.startsWith("diff --git "));
expect(headers.length).toBeGreaterThan(0);
const expectedHeaders = paths.map((path) => `diff --git a/${path} b/${path}`);
expect(expectedHeaders).toEqual(expect.arrayContaining(headers));
expect(diff).toContain("-old\n+new");
expect(diff).toContain(`${paths.length - headers.length} more files omitted`);
expect(diff.length).toBeLessThanOrEqual(12000);
});
});
describe("large multi-file additions", () => {
it("bounds a generated addition without throwing before later files are mapped", () => {
const item = mapCodexToolCallFromThreadItem(
{
type: "fileChange",
id: "large-addition",
status: "completed",
changes: [
{ path: "/repo/generated.txt", kind: { type: "add" }, diff: "x\n".repeat(150000) },
{ path: "/repo/second.txt", kind: { type: "add" }, diff: "second\n" },
],
},
{ cwd: "/repo" },
);
if (item?.detail.type !== "edit") throw new Error("Expected edit");
expect(item.status).toBe("completed");
expect(item.detail.unifiedDiff).toContain("diff --git a/generated.txt b/generated.txt");
expect(item.detail.unifiedDiff).toContain("diff --git a/second.txt b/second.txt");
expect(item.detail.unifiedDiff).toContain("+second");
expect(item.detail.unifiedDiff!.length).toBeLessThanOrEqual(12000);
});
});
describe("move-only entries in multi-file previews", () => {
it("shows a move without manufacturing added file contents from provider metadata", () => {
const item = mapCodexToolCallFromThreadItem(
{
type: "fileChange",
id: "move-only",
status: "completed",
changes: [
{
path: "/repo/old.txt",
kind: { type: "update", move_path: "/repo/new.txt" },
diff: "\n\nMoved to: /repo/new.txt",
},
{ path: "/repo/second.txt", kind: "modify", diff: "@@ -1 +1 @@\n-small\n+large\n" },
],
},
{ cwd: "/repo" },
);
if (item?.detail.type !== "edit") throw new Error("Expected edit");
expect(item.detail.unifiedDiff).toContain("rename from old.txt\nrename to new.txt");
expect(item.detail.unifiedDiff).toContain("diff --git a/second.txt b/second.txt");
expect(item.detail.unifiedDiff).not.toContain("+Moved to:");
expect(item.detail.unifiedDiff).not.toContain("@@ -0,0");
});
});
describe("single-file move compatibility", () => {
it("retains the destination outside the multi-file preview path", () => {
const item = mapCodexToolCallFromThreadItem(
{
type: "fileChange",
id: "single-move",
status: "completed",
changes: [
{
path: "/repo/old.txt",
kind: { type: "update", move_path: "/repo/new.txt" },
diff: "\n\nMoved to: /repo/new.txt",
},
],
},
{ cwd: "/repo" },
);
expect(JSON.stringify(item)).toContain("new.txt");
});
});
describe("empty additions in multi-file previews", () => {
it.each(["inProgress", "completed"])(
"keeps an empty created file when the patch is %s",
(status) => {
const item = mapCodexToolCallFromThreadItem(
{
type: "fileChange",
id: "empty-addition",
status,
changes: [
{ path: "/repo/empty.txt", kind: { type: "add" }, diff: "" },
{ path: "/repo/second.txt", kind: "modify", diff: "@@ -1 +1 @@\n-old\n+new\n" },
],
},
{ cwd: "/repo" },
);
if (item?.detail.type !== "edit") throw new Error("Expected edit");
expect(item.detail.unifiedDiff).toContain(
"diff --git a/empty.txt b/empty.txt\n--- /dev/null\n+++ b/empty.txt",
);
expect(item.detail.unifiedDiff).toContain("diff --git a/second.txt b/second.txt");
},
);
});
@@ -4,6 +4,7 @@ import type { ToolCallTimelineItem } from "../../agent-sdk-types.js";
import {
extractCodexShellOutput,
normalizeToolCallStatus,
TOOL_CALL_DIFF_MAX_CHARS,
truncateDiffText,
} from "../tool-call-mapper-utils.js";
import { deriveCodexToolDetail, normalizeCodexFilePath } from "./tool-call-detail-parser.js";
@@ -400,7 +401,7 @@ function contentToDeletionDiff(filePath: string, content: string): string {
output.push(`diff --git a/${filePath} b/${filePath}`);
output.push(`--- a/${filePath}`);
output.push(`+++ /dev/null`);
const nonEmpty = lines.filter((l) => l.length > 0 || lines.indexOf(l) < lines.length - 1);
const nonEmpty = lines.slice(0, lines.at(-1) === "" ? -1 : undefined);
if (nonEmpty.length > 0) {
output.push(`@@ -1,${nonEmpty.length} +0,0 @@`);
for (const line of nonEmpty) {
@@ -713,6 +714,7 @@ interface CodexFileChangeEntry {
path: string;
kind?: string;
diff?: string;
movePath?: string;
}
function parseFileChangePath(
@@ -740,6 +742,9 @@ function parseFileChangePath(
}
function parseFileChangeKind(entry: Record<string, unknown>): string | undefined {
if (isRecord(entry.kind) && typeof entry.kind.type === "string") {
return entry.kind.type;
}
return (
(typeof entry.kind === "string" && entry.kind) ||
(typeof entry.type === "string" && entry.type) ||
@@ -767,10 +772,14 @@ function toFileChangeEntry(
if (!path) {
return null;
}
const kind = isRecord(entry.kind) ? entry.kind : undefined;
const movePath = entry.move_path ?? kind?.move_path;
return {
path,
kind: parseFileChangeKind(entry),
diff: parseFileChangeDiff(entry),
movePath:
typeof movePath === "string" ? normalizeCodexFilePath(movePath, options?.cwd) : undefined,
};
}
@@ -839,6 +848,105 @@ function resolveFileChangeTextFields(file: CodexFileChangeEntry | undefined): {
return asEditTextFields(file.diff);
}
function fileChangeToUnifiedDiff(file: CodexFileChangeEntry): string {
// Codex formats a content-preserving move as a notice, not added file text.
const isMoveOnlyNotice = file.movePath && file.diff?.startsWith("\n\nMoved to: ");
const { unifiedDiff, newString } = isMoveOnlyNotice ? {} : resolveFileChangeTextFields(file);
if (!unifiedDiff && !newString) {
if (file.kind === "add") {
return [
`diff --git a/${file.path} b/${file.path}`,
"--- /dev/null",
`+++ b/${file.path}`,
].join("\n");
}
return file.movePath
? [
`diff --git a/${file.path} b/${file.movePath}`,
`rename from ${file.path}`,
`rename to ${file.movePath}`,
].join("\n")
: "";
}
if (unifiedDiff?.startsWith("diff --git")) {
return unifiedDiff.replace(/\r?\n$/, "");
}
const destination = file.movePath ?? file.path;
const header = `diff --git a/${file.path} b/${destination}`;
if (unifiedDiff?.startsWith("--- ")) {
return `${header}\n${unifiedDiff.replace(/\r?\n$/, "")}`;
}
const left = file.kind === "add" ? "/dev/null" : `a/${file.path}`;
const right = file.kind === "delete" ? "/dev/null" : `b/${destination}`;
const lines = [header, `--- ${left}`, `+++ ${right}`];
if (unifiedDiff) {
lines.push(unifiedDiff.replace(/\r?\n$/, ""));
} else if (newString) {
const contentLines = newString.replace(/\r\n/g, "\n").replace(/\n$/, "").split("\n");
lines.push(`@@ -0,0 +1,${contentLines.length} @@`);
for (const line of contentLines) lines.push(`+${line}`);
}
return lines.join("\n");
}
function splitFileDiffPreview(diff: string): { header: string; body: string } {
const hunk = diff.indexOf("\n@@");
const bodyStart = hunk < 0 ? -1 : diff.indexOf("\n", hunk + 1);
return bodyStart < 0
? { header: diff, body: "" }
: { header: diff.slice(0, bodyStart + 1), body: diff.slice(bodyStart + 1) };
}
export function fileChangesToPreview(files: CodexFileChangeEntry[]): {
filePath: string | undefined;
unifiedDiff: string;
} {
const diffs = files
.map((file) => ({ filePath: file.path, diff: fileChangeToUnifiedDiff(file) }))
.filter((file) => file.diff.length > 0);
const filePath = diffs[0]?.filePath ?? files[0]?.path;
const combined = diffs.map((file) => file.diff).join("\n\n");
if (combined.length <= TOOL_CALL_DIFF_MAX_CHARS) return { filePath, unifiedDiff: combined };
const marker = "\n...[truncated]";
const omittedNotice = (count: number) =>
count > 0 ? `\n\n...[${count} more files omitted]` : "";
const previews = diffs.map((file) => splitFileDiffPreview(file.diff));
const visible: typeof previews = [];
let minimumSize = 0;
for (const preview of previews) {
// Keep whole labels and a useful body excerpt, or explicitly omit the file.
const required =
preview.header.length + Math.max(marker.length, Math.min(80, preview.body.length));
const nextSize = minimumSize + required + (visible.length > 0 ? 2 : 0);
if (
nextSize + omittedNotice(previews.length - visible.length - 1).length >
TOOL_CALL_DIFF_MAX_CHARS
)
break;
visible.push(preview);
minimumSize = nextSize;
}
const omitted = omittedNotice(previews.length - visible.length);
const headersSize = visible.reduce((size, preview) => size + preview.header.length, 0);
const bodyBudget = Math.floor(
(TOOL_CALL_DIFF_MAX_CHARS -
headersSize -
Math.max(0, visible.length - 1) * 2 -
omitted.length) /
Math.max(1, visible.length),
);
const unifiedDiff =
visible
.map(
({ header, body }) =>
header +
(body.length <= bodyBudget ? body : body.slice(0, bodyBudget - marker.length) + marker),
)
.join("\n\n") + omitted;
return { filePath, unifiedDiff };
}
function mapFileChangeItem(
item: z.infer<typeof CodexFileChangeItemSchema>,
options?: CodexMapperOptions,
@@ -873,17 +981,17 @@ function mapFileChangeItem(
const error = item.error ?? null;
const status = normalizeToolCallStatus(item.status, error, output);
const firstFile = files[0];
const firstTextFields = resolveFileChangeTextFields(firstFile);
const hasFirstTextFields = Object.keys(firstTextFields).length > 0;
const preview = files.length > 1 ? fileChangesToPreview(files) : undefined;
const filePath = preview?.filePath ?? firstFile?.path;
const textFields = preview
? { unifiedDiff: preview.unifiedDiff }
: resolveFileChangeTextFields(firstFile);
const hasTextFields = Boolean(textFields.unifiedDiff || textFields.newString);
const input = toNullableObject({
...inputBase,
...(firstFile?.path && hasFirstTextFields ? { path: firstFile.path } : {}),
...(hasFirstTextFields && firstTextFields.unifiedDiff
? { patch: firstTextFields.unifiedDiff }
: {}),
...(hasFirstTextFields && firstTextFields.newString
? { content: firstTextFields.newString }
: {}),
...(filePath && hasTextFields ? { path: filePath } : {}),
...(hasTextFields && textFields.unifiedDiff ? { patch: textFields.unifiedDiff } : {}),
...(hasTextFields && textFields.newString ? { content: textFields.newString } : {}),
});
return {
@@ -145,16 +145,6 @@ function piAssistantEntry(id: string, parentId: string): PiSessionEntry {
return { type: "message", id, parentId, message: { role: "assistant", content: [] } };
}
// Pi's context path: the entries from the root to the leaf.
function piBranchTo(entries: PiSessionEntry[], leafId: string): PiSessionEntry[] {
const byId = new Map(entries.map((entry) => [entry.id, entry]));
const branch: PiSessionEntry[] = [];
for (let entry = byId.get(leafId); entry; entry = byId.get(entry.parentId ?? "")) {
branch.unshift(entry);
}
return branch;
}
function parseEntryCapture(notification: string): unknown {
const prefix = "PASEO_ENTRY_CAPTURE ";
expect(notification.startsWith(prefix)).toBe(true);
@@ -1018,7 +1008,7 @@ describe("PiRpcAgentSession", () => {
const events = new SessionEvents(session);
const fakeSession = pi.latestSession();
fakeSession.treeUserEntries = [{ id: "entry-old", parentId: null, text: "old prompt" }];
fakeSession.contextUserEntries = fakeSession.treeUserEntries;
fakeSession.historyUserEntries = fakeSession.treeUserEntries;
await session.startTurn("new prompt", { clientMessageId: "client-new" });
fakeSession.finishSubmittedUserMessage({
@@ -1670,7 +1660,7 @@ describe("PiRpcAgentSession", () => {
test("closes a history-only Pi runtime when replay fails", async () => {
const pi = new FakePi();
pi.queueSessionSetup((runtimeSession) => {
runtimeSession.getMessagesError = new Error("History unavailable");
runtimeSession.getHistoryError = new Error("History unavailable");
});
const session = await createClient(pi).resumeSession(
{
@@ -1858,7 +1848,7 @@ describe("PiRpcAgentSession", () => {
await session.close();
});
test("captures the session's user entries apart from the ones on the current branch", async () => {
test("captures all session user entries as rewind targets, including abandoned branches", async () => {
const pi = new FakePi();
const session = await createClient(pi).createSession(createConfig());
onTestFinished(() => session.close());
@@ -1876,7 +1866,6 @@ describe("PiRpcAgentSession", () => {
const context = {
sessionManager: {
getEntries: () => entries,
buildContextEntries: () => piBranchTo(entries, "two-reply"),
},
ui: { notify: (message: string) => notifications.push(message) },
};
@@ -1891,10 +1880,6 @@ describe("PiRpcAgentSession", () => {
{ id: "abandoned", parentId: "one-reply", text: "rewound away" },
{ id: "two", parentId: "one-reply", text: "second" },
],
contextEntries: [
{ id: "one", parentId: null, text: "first" },
{ id: "two", parentId: "one-reply", text: "second" },
],
},
]);
});
@@ -3369,7 +3354,7 @@ describe("PiRpcAgentClient", () => {
{ id: "entry-1", parentId: null, text: "first prompt" },
{ id: "entry-3", parentId: "entry-2", text: "second prompt" },
];
pi.latestSession().contextUserEntries = pi.latestSession().treeUserEntries;
pi.latestSession().historyUserEntries = pi.latestSession().treeUserEntries;
await session.startTurn("first prompt");
pi.latestSession().finishTurn({ role: "assistant", content: [] });
@@ -3393,7 +3378,7 @@ describe("PiRpcAgentClient", () => {
{ id: "entry-1", parentId: null, text: "first prompt" },
{ id: "entry-3", parentId: "entry-2", text: "second prompt" },
];
fakeSession.contextUserEntries = [
fakeSession.historyUserEntries = [
{ id: "entry-3", parentId: "entry-2", text: "second prompt" },
];
@@ -688,8 +688,6 @@ function createPiPaseoExtensionFile({
requestId,
// Rewind targets: rows can still show entries that a rewind or compaction left off the branch.
treeEntries: toCapturedUserEntries(ctx.sessionManager.getEntries()),
// The entries getMessages() replays, so the nth one is the nth replayed user message.
contextEntries: toCapturedUserEntries(ctx.sessionManager.buildContextEntries()),
}),
"info",
);
@@ -1213,7 +1211,6 @@ export class PiRpcAgentSession implements AgentSession {
private readonly pendingPromptResults = new Map<string, boolean>();
private readonly pendingSteerSubmissions: PiPendingSteerSubmission[] = [];
currentLeafOverrideId: string | null | undefined;
private readonly contextUserEntries: PiCapturedEntry[] = [];
private readonly treeUserEntriesById = new Map<string, PiCapturedEntry>();
private readonly pendingExtensionResults = new Map<string, PendingExtensionResult>();
private compactionPromise: Promise<void> | null = null;
@@ -1459,10 +1456,11 @@ export class PiRpcAgentSession implements AgentSession {
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
try {
await this.requestEntryCapture("history");
const history = await this.runtimeSession.getHistory();
yield* streamPiHistory(
this.provider,
await this.runtimeSession.getMessages(),
this.contextUserEntries,
history.messages,
history.userEntries,
{},
// At most eight 2 MiB child files per replay; later cards retain their summaries.
createPiExtensionHost(this.logger, undefined, 16 * 1024 * 1024),
@@ -2017,13 +2015,9 @@ export class PiRpcAgentSession implements AgentSession {
}
}
private recordCapturedUserEntries(input: {
treeEntries: PiCapturedEntry[];
contextEntries: PiCapturedEntry[];
}): void {
this.contextUserEntries.splice(0, this.contextUserEntries.length, ...input.contextEntries);
private recordCapturedUserEntries(entries: PiCapturedEntry[]): void {
this.treeUserEntriesById.clear();
for (const entry of input.treeEntries) {
for (const entry of entries) {
this.treeUserEntriesById.set(entry.id, entry);
}
}
@@ -2060,10 +2054,7 @@ export class PiRpcAgentSession implements AgentSession {
if (!payload) {
return false;
}
this.recordCapturedUserEntries({
treeEntries: parseCapturedEntries(payload.treeEntries),
contextEntries: parseCapturedEntries(payload.contextEntries),
});
this.recordCapturedUserEntries(parseCapturedEntries(payload.treeEntries));
if (typeof payload.requestId === "string") {
this.resolveExtensionResult(payload.requestId, undefined);
}
@@ -1,3 +1,5 @@
import { mkdir, mkdtemp, writeFile, readFile, rm } from "node:fs/promises";
import path from "node:path";
import type { ChildProcessWithoutNullStreams } from "node:child_process";
import { EventEmitter } from "node:events";
import { PassThrough } from "node:stream";
@@ -545,3 +547,102 @@ describe("PiCliRuntime", () => {
await expect(session.abort()).resolves.toBeUndefined();
});
});
async function createHistoryTestDirectory(prefix: string): Promise<string> {
const artifacts = path.resolve(".artifacts/pi-timeline");
await mkdir(artifacts, { recursive: true });
return mkdtemp(path.join(artifacts, prefix));
}
test("reads complete persisted history instead of compacted RPC context", async () => {
const root = await createHistoryTestDirectory("runtime-");
const file = path.join(root, "session.jsonl");
const records = [
{ type: "session", version: 3, id: "session" },
{
type: "message",
id: "old-user",
parentId: "system",
message: { role: "user", content: "before compaction" },
},
{
type: "message",
id: "old-answer",
parentId: "old-user",
message: { role: "assistant", content: [{ type: "text", text: "old answer" }] },
},
{ type: "compaction", id: "compact", parentId: "old-answer", summary: "summary" },
{
type: "message",
id: "new-user",
parentId: "compact",
message: { role: "user", content: "after compaction" },
},
];
records.splice(1, 0, {
type: "message",
id: "system",
parentId: null,
message: { role: "system", content: "private instructions" },
});
const contents = records.map((row) => JSON.stringify(row)).join("\n");
await writeFile(file, contents);
const child = createPiChild();
onPiCommand(child, (command) =>
writePiResponse(
child,
command,
command.type === "get_state" ? { sessionFile: file } : { messages: [records[5].message] },
),
);
const runtime = await createRuntime(child).startSession({ cwd: root });
try {
const history = await runtime.getHistory();
expect(history.userEntries.map((entry) => entry.id)).toEqual(["old-user", "new-user"]);
expect(history.messages).toEqual([records[2].message, records[3].message, records[5].message]);
expect(await readFile(file, "utf8")).toBe(contents);
await writeFile(
file,
contents +
'\n{"type":"custom","id":"rewind","parentId":"old-answer","customType":"paseo_rewind"}',
);
expect((await runtime.getHistory()).userEntries.map((entry) => entry.id)).toEqual(["old-user"]);
await writeFile(
file,
contents + '\n{"type":"custom","id":"rewind","parentId":null,"customType":"paseo_rewind"}',
);
expect((await runtime.getHistory()).messages).toEqual([]);
await writeFile(file, contents + '\n{"type":"custom","id":"bad","parentId":"missing"}');
await expect(runtime.getHistory()).rejects.toThrow("Missing Pi transcript ancestor");
} finally {
await runtime.close();
await rm(root, { recursive: true, force: true });
}
});
test("permits the unpersisted empty new session but refuses a missing resumed transcript", async () => {
const root = await createHistoryTestDirectory("empty-");
const file = path.join(root, "not-created.jsonl");
const child = createPiChild();
onPiCommand(child, (command) =>
writePiResponse(child, command, { sessionFile: file, messageCount: 0 }),
);
const runtime = createRuntime(child);
const fresh = await runtime.startSession({ cwd: root });
try {
expect(await fresh.getHistory()).toEqual({ messages: [], userEntries: [] });
} finally {
await fresh.close();
}
const resumedChild = createPiChild();
onPiCommand(resumedChild, (command) =>
writePiResponse(resumedChild, command, { sessionFile: file, messageCount: 0 }),
);
const resumed = await createRuntime(resumedChild).startSession({ cwd: root, session: file });
try {
await expect(resumed.getHistory()).rejects.toThrow("ENOENT");
} finally {
await resumed.close();
await rm(root, { recursive: true, force: true });
}
});
@@ -1,3 +1,5 @@
import { readFile } from "node:fs/promises";
import { readPiTranscript, type PiTranscript } from "./transcript.js";
import { createExternalProcessEnv } from "../../../paseo-env.js";
import type { ChildProcessWithoutNullStreams } from "node:child_process";
import type { Logger } from "pino";
@@ -16,7 +18,6 @@ import {
type PiStartSessionInput,
} from "./runtime.js";
import type {
PiAgentMessage,
PiModel,
PiPromptAck,
PiRpcCommand,
@@ -78,18 +79,27 @@ export class PiCliRuntime implements PiRuntime {
await process.close(input.signal.reason);
input.signal.throwIfAborted();
}
return new PiCliRuntimeSession(process, this.commandsRpcName, launch.env);
return new PiCliRuntimeSession({
process,
commandsRpcName: this.commandsRpcName,
launchEnvironment: launch.env,
newSession: input.session === undefined,
});
}
}
interface PiCliRuntimeSessionOptions {
process: JsonlRpcProcess;
commandsRpcName: string;
launchEnvironment: Record<string, string>;
newSession: boolean;
}
class PiCliRuntimeSession implements PiRuntimeSession {
private readonly subscribers = new Set<(event: PiRuntimeEvent) => void>();
constructor(
private readonly process: JsonlRpcProcess,
private readonly commandsRpcName: string,
private readonly launchEnvironment: Record<string, string>,
) {
constructor(private readonly options: PiCliRuntimeSessionOptions) {
const { process } = options;
process.onMessage((message) => {
this.emit(message as PiRuntimeEvent);
});
@@ -99,7 +109,7 @@ class PiCliRuntimeSession implements PiRuntimeSession {
}
get environment(): Record<string, string> {
return this.launchEnvironment;
return this.options.launchEnvironment;
}
onEvent(callback: (event: PiRuntimeEvent) => void): () => void {
@@ -113,7 +123,7 @@ class PiCliRuntimeSession implements PiRuntimeSession {
message: string,
images?: Array<{ type: "image"; data: string; mimeType: string }>,
): Promise<PiPromptAck> {
const { id: requestId, promise } = this.process.startRequest({
const { id: requestId, promise } = this.options.process.startRequest({
type: "prompt",
message,
...(images?.length ? { images } : {}),
@@ -162,9 +172,23 @@ class PiCliRuntimeSession implements PiRuntimeSession {
return (await this.request({ type: "get_state" })) as PiSessionState;
}
async getMessages(): Promise<PiAgentMessage[]> {
const data = (await this.request({ type: "get_messages" })) as { messages?: PiAgentMessage[] };
return data.messages ?? [];
async getHistory(): Promise<PiTranscript> {
const state = await this.getState();
if (!state.sessionFile) throw new Error("Pi history requires a persisted session file");
try {
return readPiTranscript(await readFile(state.sessionFile, "utf8"));
} catch (error) {
// Pi does not create the file until the first conversation message is saved.
if (
this.options.newSession &&
state.messageCount === 0 &&
error instanceof Error &&
"code" in error &&
error.code === "ENOENT"
)
return { messages: [], userEntries: [] };
throw error;
}
}
async getAvailableModels(timeoutMs?: number | null): Promise<PiModel[]> {
@@ -215,21 +239,21 @@ class PiCliRuntimeSession implements PiRuntimeSession {
}
async getCommands(): Promise<PiRpcSlashCommand[]> {
const data = (await this.request({ type: this.commandsRpcName })) as {
const data = (await this.request({ type: this.options.commandsRpcName })) as {
commands?: PiRpcSlashCommand[];
};
return data.commands ?? [];
}
sendRawFrame(frame: object & { type: string }): void {
this.process.send(frame);
this.options.process.send(frame);
}
respondToExtensionUiRequest(
id: string,
response: { value?: string; confirmed?: boolean; cancelled?: boolean },
): void {
this.process.send({ type: "extension_ui_response", id, ...response });
this.options.process.send({ type: "extension_ui_response", id, ...response });
}
cancelExtensionUiRequest(id: string): void {
@@ -237,22 +261,22 @@ class PiCliRuntimeSession implements PiRuntimeSession {
}
async close(): Promise<void> {
await this.process.close(new Error("Pi RPC session is closed"));
await this.options.process.close(new Error("Pi RPC session is closed"));
}
request(command: PiRpcCommand, timeoutMs?: number | null): Promise<unknown> {
return this.process.request(command, timeoutMs);
return this.options.process.request(command, timeoutMs);
}
private requestStopWork(command: PiRpcCommand): Promise<void> {
return this.process.requestStopWork(command);
return this.options.process.requestStopWork(command);
}
private async waitForCompletion(command: PiRpcCommand): Promise<void> {
// Pi only replies after its compaction work is durable. Its child process and
// session close paths already reject pending RPCs, so no elapsed-time failure
// is useful here.
await this.process.request(command, JSONL_RPC_NO_TIMEOUT);
await this.options.process.request(command, JSONL_RPC_NO_TIMEOUT);
}
private emit(event: PiRuntimeEvent): void {
@@ -1,3 +1,5 @@
import { buildAgentForkContextAttachment } from "../../activity-curator.js";
import { readPiTranscript } from "./transcript.js";
import { describe, expect, test } from "vitest";
import type { AgentStreamEvent } from "../../agent-sdk-types.js";
@@ -293,3 +295,235 @@ test("replayed Pi images keep their position before the following reply", async
),
).toEqual(["tool_call", expect.stringContaining("![Image]("), "Image read complete."]);
});
test("replays both sides of compaction with native IDs and isolates rewound branches", async () => {
const records = [
{ type: "session", version: 3, id: "session" },
{
type: "message",
id: "u1",
parentId: null,
message: { role: "user", content: "old question" },
},
{
type: "message",
id: "a1",
parentId: "u1",
message: { role: "assistant", content: [{ type: "text", text: "old answer" }] },
},
{ type: "compaction", id: "c1", parentId: "a1", summary: "summary", firstKeptEntryId: "a1" },
{ type: "message", id: "u2", parentId: "c1", message: { role: "user", content: "follow up" } },
];
const decode = (rows: unknown[]) =>
readPiTranscript(rows.map((row) => JSON.stringify(row)).join("\n"));
const history = decode(records);
expect(history.userEntries.map((entry) => entry.id)).toEqual(["u1", "u2"]);
expect(history.messages.map((message) => message.role)).toEqual(["user", "assistant", "user"]);
expect(await collectHistory(history.messages, history.userEntries)).toHaveLength(3);
const rewound = [
...records,
{ type: "custom", id: "r1", parentId: null, customType: "paseo_rewind" },
];
expect(decode(rewound).messages).toEqual([]);
const replacement = [
...rewound,
{
type: "message",
id: "u3",
parentId: "r1",
message: { role: "user", content: "replacement" },
},
];
expect(decode(replacement).userEntries.map((entry) => entry.id)).toEqual(["u3"]);
expect(
decode([
...replacement,
{ type: "custom", id: "r2", parentId: "a1", customType: "paseo_rewind" },
]).userEntries.map((entry) => entry.id),
).toEqual(["u1"]);
});
test("preserves native custom entries and legacy linear messages without inventing native IDs", async () => {
const custom = {
type: "custom_message",
id: "custom",
parentId: null,
customType: "notice",
content: "visible notice",
display: true,
details: { key: 1 },
};
const history = readPiTranscript(
[JSON.stringify({ type: "session", version: 3 }), JSON.stringify(custom)].join("\n"),
);
expect(history.messages).toEqual([{ ...custom, role: "custom" }]);
expect(await collectHistory(history.messages, history.userEntries)).toHaveLength(1);
const legacy = readPiTranscript(
'{"type":"session","version":1}\n{"type":"message","message":{"role":"user","content":"legacy"}}',
);
expect(legacy.messages).toEqual([{ role: "user", content: "legacy" }]);
expect(legacy.userEntries).toEqual([]);
});
test.each([
[{ type: "message", id: "x", parentId: "x", message: { role: "user", content: "cycle" } }],
[{ type: "message", id: "x", parentId: "missing", message: { role: "user", content: "orphan" } }],
[
{ type: "custom", id: "x", parentId: null },
{ type: "custom", id: "x", parentId: null },
],
[{ type: "custom", id: "x" }],
])("rejects corrupt ancestry rather than replaying a plausible branch: %j", (...entries) => {
expect(() =>
readPiTranscript(
[
JSON.stringify({ type: "session", version: 3 }),
...entries.map((entry) => JSON.stringify(entry)),
].join("\n"),
),
).toThrow();
});
test("Fork chat stops at a restored pre-compaction reply on the active branch", async () => {
const entries = [
{ type: "session", version: 3 },
{
type: "message",
id: "u1",
parentId: null,
message: { role: "user", content: "retained question" },
},
{
type: "message",
id: "a1",
parentId: "u1",
message: { role: "assistant", content: [{ type: "text", text: "retained answer" }] },
},
{
type: "message",
id: "abandoned",
parentId: "a1",
message: { role: "user", content: "abandoned branch" },
},
{ type: "compaction", id: "c1", parentId: "a1", summary: "summary" },
{
type: "message",
id: "later",
parentId: "c1",
message: { role: "user", content: "later question" },
},
];
const transcript = readPiTranscript(entries.map((entry) => JSON.stringify(entry)).join("\n"));
const events = await collectHistory(transcript.messages, transcript.userEntries);
const rows = events
.filter((event) => event.type === "timeline")
.map((event, index) => ({
seq: index + 1,
timestamp: "2026-10-31T00:00:00.000Z",
item: event.item,
}));
const fork = buildAgentForkContextAttachment({
rows,
cursorBoundary: { cursor: { epoch: "test", seq: 2 }, timelineEpoch: "test" },
});
expect(fork.attachment.text).toContain("retained question");
expect(fork.attachment.text).toContain("retained answer");
expect(fork.attachment.text).not.toContain("abandoned branch");
expect(fork.attachment.text).not.toContain("later question");
expect(transcript.userEntries.map((entry) => entry.id)).toEqual(["u1", "later"]);
});
test("keeps reasoning, image content and tool results across structural entries", async () => {
const messages: PiAgentMessage[] = [
{
role: "user",
content: [
{ type: "image", data: "aGVsbG8=", mimeType: "image/png" },
{ type: "text", text: "inspect" },
],
},
{
role: "assistant",
content: [
{ type: "thinking", thinking: "consider" },
{ type: "toolCall", id: "call", name: "read", arguments: { path: "a.txt" } },
],
},
{
role: "toolResult",
toolCallId: "call",
toolName: "read",
content: [{ type: "text", text: "file contents" }],
},
{ role: "assistant", content: [{ type: "text", text: "answer" }] },
];
const records = [
{ type: "session", version: 3 },
...messages.map((message, index) => ({
type: "message",
id: String(index),
parentId: index === 0 ? null : String(index - 1),
message,
})),
{ type: "compaction", id: "compact", parentId: "3", summary: "summary" },
];
const history = readPiTranscript(records.map((record) => JSON.stringify(record)).join("\n"));
expect(history.messages).toEqual(messages);
expect(history.userEntries).toEqual([{ id: "0", text: "inspect" }]);
expect(await collectHistory(history.messages, history.userEntries)).toEqual(
await collectHistory(messages, [{ id: "0", text: "inspect" }]),
);
});
test("traverses native system and summary messages without displaying private context", () => {
const roles = [
{ role: "system", content: "private instructions" },
{ role: "user", content: "question" },
{ role: "branchSummary", summary: "branch context" },
{ role: "compactionSummary", summary: "compacted context" },
{ role: "system", content: null },
];
const rows = [
{ type: "session", version: 3 },
...roles.map((message, index) => ({
type: "message",
id: String(index),
parentId: index === 0 ? null : String(index - 1),
message,
})),
];
expect(readPiTranscript(rows.map((row) => JSON.stringify(row)).join("\n"))).toEqual({
messages: [{ role: "user", content: "question" }],
userEntries: [{ id: "1", text: "question" }],
});
});
test("restores surrounding chat when native system content uses text blocks", async () => {
const records = [
{ type: "session", version: 3 },
{ type: "message", id: "u", parentId: null, message: { role: "user", content: "question" } },
{
type: "message",
id: "s",
parentId: "u",
message: { role: "system", content: [{ type: "text", text: "private instructions" }] },
},
{
type: "message",
id: "a",
parentId: "s",
message: { role: "assistant", content: [{ type: "text", text: "answer" }] },
},
];
const history = readPiTranscript(records.map((record) => JSON.stringify(record)).join("\n"));
expect(history.messages).toEqual([
{ role: "user", content: "question" },
{ role: "assistant", content: [{ type: "text", text: "answer" }] },
]);
expect(history.userEntries).toEqual([{ id: "u", text: "question" }]);
const events = await collectHistory(history.messages, history.userEntries);
expect(events.map((event) => event.item)).toEqual([
{ type: "user_message", text: "question", messageId: "u" },
{ type: "assistant_message", text: "answer", messageId: "pi-history-assistant-1" },
]);
});
@@ -1,5 +1,5 @@
import type { PiTranscript } from "./transcript.js";
import type {
PiAgentMessage,
PiModel,
PiPromptAck,
PiRpcSlashCommand,
@@ -55,7 +55,7 @@ export interface PiRuntimeSession {
setAutoCompaction(enabled: boolean): Promise<void>;
abort(): Promise<void>;
getState(): Promise<PiSessionState>;
getMessages(): Promise<PiAgentMessage[]>;
getHistory(): Promise<PiTranscript>;
getAvailableModels(timeoutMs?: number | null): Promise<PiModel[]>;
setModel(provider: string, modelId: string): Promise<PiModel>;
setThinkingLevel(level: string): Promise<void>;
@@ -150,8 +150,8 @@ export class FakePiSession implements PiRuntimeSession {
readonly rawFrames: Array<object & { type: string }> = [];
// Every user entry in the session file, including rewound and compacted ones.
treeUserEntries: FakePiUserEntry[] = [];
// The user entries on the current branch that getMessages() replays.
contextUserEntries: FakePiUserEntry[] = [];
// Native user entries paired with the complete active-branch history.
historyUserEntries: FakePiUserEntry[] = [];
abortRequested = false;
closeCalls = 0;
readonly canceledExtensionUiRequests: string[] = [];
@@ -162,7 +162,7 @@ export class FakePiSession implements PiRuntimeSession {
setModelResult: PiModel | null = null;
models: PiModel[] = [];
messages: PiAgentMessage[] = [];
getMessagesError: Error | null = null;
getHistoryError: Error | null = null;
stats: PiSessionStats = {
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
cost: 0,
@@ -304,9 +304,9 @@ export class FakePiSession implements PiRuntimeSession {
return this.state;
}
async getMessages(): Promise<PiAgentMessage[]> {
if (this.getMessagesError) throw this.getMessagesError;
return this.messages;
async getHistory() {
if (this.getHistoryError) throw this.getHistoryError;
return { messages: this.messages, userEntries: this.historyUserEntries };
}
async getAvailableModels(_timeoutMs?: number | null): Promise<PiModel[]> {
@@ -513,7 +513,6 @@ export class FakePiSession implements PiRuntimeSession {
reason,
requestId,
treeEntries: this.treeUserEntries,
contextEntries: this.contextUserEntries,
})}`,
});
}
@@ -0,0 +1,134 @@
import { z } from "zod";
import { getUserMessageText, type PiCapturedUserMessageEntry } from "./history-mapper.js";
import type { PiAgentMessage } from "./rpc-types.js";
export interface PiTranscript {
messages: PiAgentMessage[];
userEntries: PiCapturedUserMessageEntry[];
}
const text = z.object({ type: z.literal("text"), text: z.string() }).passthrough();
const image = z
.object({ type: z.literal("image"), data: z.string(), mimeType: z.string() })
.passthrough();
const content = z.union([z.string(), z.array(z.union([text, image]))]);
const message = z.discriminatedUnion("role", [
z
.object({ role: z.literal("system"), content: z.union([z.string(), z.array(text)]).nullish() })
.passthrough(),
z.object({ role: z.literal("branchSummary"), summary: z.string() }).passthrough(),
z.object({ role: z.literal("compactionSummary"), summary: z.string() }).passthrough(),
z.object({ role: z.literal("user"), content }).passthrough(),
z
.object({
role: z.literal("custom"),
content,
customType: z.string().optional(),
display: z.boolean().optional(),
details: z.unknown().optional(),
})
.passthrough(),
z
.object({
role: z.literal("assistant"),
content: z.array(
z.union([
text,
z.object({ type: z.literal("thinking"), thinking: z.string() }).passthrough(),
z
.object({
type: z.literal("toolCall"),
id: z.string(),
name: z.string(),
arguments: z.unknown(),
})
.passthrough(),
]),
),
})
.passthrough(),
z
.object({
role: z.literal("toolResult"),
toolCallId: z.string(),
toolName: z.string(),
content: z.unknown(),
isError: z.boolean().optional(),
details: z.unknown().optional(),
})
.passthrough(),
z
.object({
role: z.literal("bashExecution"),
command: z.string(),
output: z.string().optional(),
exitCode: z.number().nullable().optional(),
cancelled: z.boolean().optional(),
timestamp: z.number(),
})
.passthrough(),
]);
const record = z
.object({
type: z.string(),
id: z.string().optional(),
parentId: z.string().nullable().optional(),
message: z.unknown().optional(),
})
.passthrough();
/** Select the persisted active ancestry, retaining structural links across compaction. */
export function readPiTranscript(contents: string): PiTranscript {
const records = contents
.split("\n")
.filter((line) => line.trim())
.map((line) => record.parse(JSON.parse(line)));
const header = records.shift();
if (header?.type !== "session") throw new Error("Pi transcript is missing its session header");
const legacy = header.version === 1;
const branch = legacy ? records : activeBranch(records);
const result: PiTranscript = { messages: [], userEntries: [] };
for (const entry of branch) {
if (entry.type !== "message" && entry.type !== "custom_message") continue;
const parsed = message.parse(
entry.type === "custom_message" ? { ...entry, role: "custom" } : entry.message,
);
// These native context messages had no display mapping in get_messages replay.
if (
parsed.role === "system" ||
parsed.role === "branchSummary" ||
parsed.role === "compactionSummary"
)
continue;
result.messages.push(parsed);
if (parsed.role === "user" && entry.id)
result.userEntries.push({
id: entry.id,
text: getUserMessageText(parsed.content),
});
}
return result;
}
function activeBranch(records: z.infer<typeof record>[]): z.infer<typeof record>[] {
const byId = new Map<string, (typeof records)[number]>();
for (const entry of records) {
if (!entry.id || entry.parentId === undefined || byId.has(entry.id))
throw new Error("Invalid Pi transcript tree entry");
byId.set(entry.id, entry);
}
const branch: z.infer<typeof record>[] = [];
const seen = new Set<string>();
let entry = records.at(-1);
while (entry) {
const id = entry.id!;
if (seen.has(id)) throw new Error("Cycle in Pi transcript ancestry");
seen.add(id);
branch.push(entry);
if (entry.parentId === null) break;
entry = byId.get(entry.parentId!);
if (!entry) throw new Error("Missing Pi transcript ancestor");
}
branch.reverse();
return branch;
}
@@ -224,9 +224,11 @@ export function stripReadLineNumberGutter(
return { content: stripped.join("\n"), startLine };
}
export const TOOL_CALL_DIFF_MAX_CHARS = 12_000;
export function truncateDiffText(
text: string | undefined,
maxChars: number = 12_000,
maxChars: number = TOOL_CALL_DIFF_MAX_CHARS,
): string | undefined {
if (typeof text !== "string") {
return undefined;
@@ -30,6 +30,7 @@ function createPullRequest(
state: "open",
baseRefName: "main",
headRefName: "feature",
headSha: "same-head",
isMerged: true,
...overrides,
};
@@ -46,6 +47,7 @@ function createSnapshot(overrides?: {
repoRoot: "/tmp/repo",
mainRepoRoot: "/tmp/repo",
currentBranch: "feature",
headSha: "same-head",
remoteUrl: "https://github.com/acme/repo.git",
isPaseoOwnedWorktree: true,
isDirty: false,
@@ -346,6 +348,25 @@ afterEach(() => {
});
describe("archiveIfSafe", () => {
test("denies archive when checkout facts do not establish safety", async () => {
for (const git of [{ headSha: undefined }, { behindOfOrigin: 1 }, { isDirty: null }]) {
const harness = createHarness({ snapshot: createSnapshot({ git }) });
await runArchiveIfSafe(harness);
expect(harness.deps.archiveByScope).not.toHaveBeenCalled();
}
const harness = createHarness({
snapshot: createSnapshot({ pullRequest: createPullRequest({ headSha: undefined }) }),
});
await runArchiveIfSafe(harness);
expect(harness.deps.archiveByScope).not.toHaveBeenCalled();
});
test("keeps the workspace active when the merged PR head differs from checkout", async () => {
const harness = createHarness({ snapshot: createSnapshot({ git: { headSha: "local-head" } }) });
await runArchiveIfSafe(harness);
expect(harness.deps.archiveByScope).not.toHaveBeenCalled();
});
beforeEach(() => {
vi.clearAllMocks();
});
@@ -59,13 +59,21 @@ export async function archiveIfSafe(input: {
const cwd = snapshot.cwd;
const pullRequest = snapshot.forge.pullRequest;
if (!pullRequest?.isMerged) {
if (
!pullRequest?.isMerged ||
!snapshot.git.headSha ||
snapshot.git.headSha !== pullRequest.headSha ||
snapshot.forge.error
) {
return;
}
if (snapshot.git.isDirty === true) {
if (snapshot.git.isDirty !== false) {
return;
}
if (typeof snapshot.git.aheadOfOrigin === "number" && snapshot.git.aheadOfOrigin > 0) {
if (
(snapshot.git.aheadOfOrigin !== null && snapshot.git.aheadOfOrigin !== 0) ||
(snapshot.git.behindOfOrigin !== null && snapshot.git.behindOfOrigin !== 0)
) {
return;
}
@@ -161,7 +161,12 @@ async function createWorkspaceJourney() {
};
}
async function createUnobservedWorkspace(archiveBarrier?: Promise<void>) {
async function createUnobservedWorkspace(journeyOptions?: {
archiveBarrier?: Promise<void>;
changeBeforeArchiveRead?: boolean;
}) {
const { archiveBarrier, changeBeforeArchiveRead: shouldChange = false } = journeyOptions ?? {};
let changeBeforeArchiveRead = shouldChange;
const { repoDir, paseoHome, worktree } = await createWorkspaceFiles();
const cwd = worktree.worktreePath;
run(repoDir, ["remote", "add", "origin", "https://github.com/acme/repo.git"]);
@@ -173,8 +178,10 @@ async function createUnobservedWorkspace(archiveBarrier?: Promise<void>) {
runner: async () => ({ stdout: "", stderr: "" }),
});
let observation: PullRequestObservation | null = null;
let remoteHeadSha = execFileSync("git", ["rev-parse", "HEAD"], { cwd, encoding: "utf8" }).trim();
const reads: Array<string | undefined> = [];
const archives: string[] = [];
let archiveDecisions = 0;
let active = true;
const service = new WorkspaceGitServiceImpl({
logger,
@@ -186,6 +193,7 @@ async function createUnobservedWorkspace(archiveBarrier?: Promise<void>) {
return {
status: observation && {
...observation,
headSha: remoteHeadSha,
title: observation.headRefName,
baseRefName: "main",
isMerged: observation.state === "merged",
@@ -202,15 +210,29 @@ async function createUnobservedWorkspace(archiveBarrier?: Promise<void>) {
logger,
paseoHome,
daemonConfigStore: { get: () => ({ autoArchiveAfterMerge: true }) },
workspaceGitService: service,
workspaceGitService: {
onSnapshotUpdated: service.onSnapshotUpdated.bind(service),
getSnapshot: async (
snapshotCwd: string,
readOptions: { force: true; afterInFlight?: boolean; reason: string },
) => {
if (changeBeforeArchiveRead && readOptions.reason === "auto-archive-on-merge") {
changeBeforeArchiveRead = false;
writeFileSync(path.join(cwd, "README.md"), "changed before archive\n");
run(cwd, ["add", "README.md"]);
run(cwd, ["-c", "commit.gpgsign=false", "commit", "-m", "new commit"]);
}
return service.getSnapshot(snapshotCwd, readOptions);
},
},
getAutoArchivedChangeRequestUrl: async () => null,
listActiveWorkspaces: async () =>
active ? [{ workspaceId: "workspace-under-test", cwd, kind: "worktree" }] : [],
} as unknown as AutoArchiveOnMergeOptions;
setupAutoArchiveOnMerge(options, {
resolvePath: path.resolve,
archiveIfSafe: async (input) =>
archiveIfSafe({
archiveIfSafe: async (input) => {
await archiveIfSafe({
...input,
deps: {
isPaseoOwnedWorktreeCwd,
@@ -227,13 +249,19 @@ async function createUnobservedWorkspace(archiveBarrier?: Promise<void>) {
};
},
},
}),
});
archiveDecisions++;
},
});
return {
archives,
archiveDecisions: () => archiveDecisions,
reads,
metrics: () => service.getMetrics(),
isActive: () => active,
advanceRemoteHead: () => {
remoteHeadSha = "a".repeat(40);
},
makeDirty: () => writeFileSync(path.join(cwd, "README.md"), "uncommitted work\n"),
commitUnpushed: () => {
writeFileSync(path.join(cwd, "README.md"), "unpushed work\n");
@@ -270,12 +298,33 @@ afterEach(async () => {
});
describe("workspace auto-archive behavior", () => {
test("rechecks checkout changes made after the merge notification", async () => {
const workspace = await createUnobservedWorkspace({ changeBeforeArchiveRead: true });
await workspace.observe("open");
await workspace.observe("merged");
await vi.waitFor(() => expect(workspace.archiveDecisions()).toBe(1));
expect(workspace.archives).toEqual([]);
expect(workspace.isActive()).toBe(true);
});
test("keeps a merged PR visible and the workspace active when its remote head advances", async () => {
const workspace = await createUnobservedWorkspace();
const open = await workspace.observe("open");
workspace.advanceRemoteHead();
const merged = await workspace.observe("merged");
await vi.waitFor(() => expect(workspace.archiveDecisions()).toBe(1));
expect(merged.forge.pullRequest?.url).toBe(open.forge.pullRequest?.url);
expect(merged.forge.pullRequest?.headSha).not.toBe(merged.git.headSha);
expect(workspace.isActive()).toBe(true);
expect(workspace.archives).toEqual([]);
});
test("archives a clean owned workspace on an unobserved open-to-merged transition only once", async () => {
let releaseArchive!: () => void;
const archiveBarrier = new Promise<void>((resolve) => {
releaseArchive = resolve;
});
const workspace = await createUnobservedWorkspace(archiveBarrier);
const workspace = await createUnobservedWorkspace({ archiveBarrier });
try {
await workspace.observe("open");
expect(workspace.archives).toEqual([]);
@@ -292,10 +341,10 @@ describe("workspace auto-archive behavior", () => {
expect(workspace.metrics().workspaceListenerCount).toBe(0);
expect(workspace.archives).toEqual(["workspace-under-test"]);
// Confirmation reads the completed snapshot cache; it does not shell out again.
expect(workspace.reads).toEqual([
"workspace-journey",
"workspace-journey",
"auto-archive-on-merge",
"workspace-journey",
]);
} finally {
@@ -90,6 +90,11 @@ test("fans one fresh observation out to every workspace attached to its exact cw
await finished;
expect(getSnapshot).toHaveBeenCalledTimes(1);
expect(getSnapshot).toHaveBeenCalledWith("/repo/worktree/.", {
force: true,
afterInFlight: true,
reason: "auto-archive-on-merge",
});
expect(calls).toEqual([
{ workspaceId: "workspace-a", pullRequest: freshSnapshot.forge.pullRequest },
{ workspaceId: "workspace-b", pullRequest: freshSnapshot.forge.pullRequest },
@@ -63,6 +63,8 @@ export function setupAutoArchiveOnMerge(
let freshSnapshot: WorkspaceGitRuntimeSnapshot | null;
try {
freshSnapshot = await options.workspaceGitService.getSnapshot(snapshot.cwd, {
force: true,
afterInFlight: true,
reason: "auto-archive-on-merge",
});
} catch (error) {
@@ -84,6 +84,23 @@ describe("daemon web UI bootstrap", () => {
}
});
test("returns 404 for missing metadata while serving web UI deep links", async () => {
const distDir = await createWebUiDist();
daemonHandle = await createTestPaseoDaemon({
mcpEnabled: true,
webUi: { enabled: true, distDir },
});
const origin = `http://127.0.0.1:${daemonHandle.port}`;
const metadata = await fetch(`${origin}/.well-known/oauth-protected-resource/mcp/agents`);
expect(metadata.status).toBe(404);
expect(metadata.headers.get("content-type")).toBe("application/json; charset=utf-8");
expect(await metadata.json()).toEqual({ error: "Not found" });
const deepLink = await fetch(`${origin}/h/example/agent/123`);
expect(deepLink.status).toBe(200);
expect(await deepLink.text()).toContain("<body>app</body>");
});
test("advertises ComputerName as the hostname over HTTP and WebSocket", async () => {
daemonHandle = await createTestPaseoDaemon({ mcpEnabled: false });
const response = await fetch(`http://127.0.0.1:${daemonHandle.port}/api/status`);
@@ -1,5 +1,7 @@
import { mkdtempSync, rmSync } from "node:fs";
import { readdir } from "node:fs/promises";
import { readdir, mkdir, mkdtemp, writeFile, readFile, rm } from "node:fs/promises";
import { randomUUID } from "node:crypto";
import { PiRpcAgentClient } from "../agent/providers/pi/agent.js";
import { tmpdir } from "node:os";
import path from "node:path";
import pino from "pino";
@@ -366,3 +368,213 @@ describe("daemon E2E (real pi) - rewind", () => {
}
}, 900_000);
});
test("seeded compacted Pi history survives daemon reload, native rewind and Fork chat", async (context) => {
if (!(await canRunRealProvider("pi"))) {
context.skip();
return;
}
const artifacts = path.resolve(".artifacts/pi-timeline");
await mkdir(artifacts, { recursive: true });
const root = await mkdtemp(path.join(artifacts, "daemon-history-"));
const cwd = path.join(root, "cwd");
await mkdir(cwd);
const file = path.join(root, "session.jsonl");
const nativeId = randomUUID();
const timestamp = new Date().toISOString();
const assistant = (text: string) => ({
role: "assistant",
content: [{ type: "text", text }],
api: "openai-completions",
provider: "openrouter",
model: "google/gemini-2.5-flash-lite",
stopReason: "stop",
timestamp: Date.now(),
usage: {
input: 1,
output: 1,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 2,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
});
const records = [
{ type: "session", version: 3, id: nativeId, cwd, timestamp },
{
type: "message",
id: "system",
parentId: null,
timestamp,
message: {
role: "system",
content: [{ type: "text", text: "private system context" }],
timestamp: Date.now(),
},
},
{
type: "message",
id: "u1",
parentId: "system",
timestamp,
message: { role: "user", content: "old question", timestamp: Date.now() },
},
{
type: "message",
id: "tool-request",
parentId: "u1",
timestamp,
message: {
...assistant(""),
content: [
{ type: "toolCall", id: "read-call", name: "read", arguments: { path: "readme.txt" } },
],
},
},
{
type: "message",
id: "tool-result",
parentId: "tool-request",
timestamp,
message: {
role: "toolResult",
toolCallId: "read-call",
toolName: "read",
content: [{ type: "text", text: "saved tool output" }],
isError: false,
timestamp: Date.now(),
},
},
{
type: "message",
id: "a1",
parentId: "tool-result",
timestamp,
message: assistant("old answer"),
},
{
type: "message",
id: "abandoned",
parentId: "a1",
timestamp,
message: { role: "user", content: "abandoned question", timestamp: Date.now() },
},
{
type: "compaction",
id: "compact",
parentId: "a1",
timestamp,
summary: "old summary",
firstKeptEntryId: null,
tokensBefore: 50000,
},
{
type: "message",
id: "u2",
parentId: "compact",
timestamp,
message: { role: "user", content: "follow up", timestamp: Date.now() },
},
{ type: "message", id: "a2", parentId: "u2", timestamp, message: assistant("new answer") },
];
await writeFile(file, records.map((record) => JSON.stringify(record)).join("\n") + "\n");
const logger = pino({ level: "silent" });
const daemon = await createTestPaseoDaemon({
agentClients: { pi: new PiRpcAgentClient({ logger }) },
logger,
paseoHomeRoot: path.join(root, "home"),
});
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
try {
await client.connect();
await client.fetchAgents({ subscribe: {} });
const imported = await client.importAgent({ provider: "pi", sessionId: file, cwd });
const session = { agentId: imported.id, cwd };
const expected = { userTexts: ["old question", "follow up"], assistantCount: 2 };
expectTimeline(await fetchTimelineItems(client, imported.id), expected);
expect(
userMessageIdForToken(await fetchTimelineItems(client, imported.id), "old question"),
).toBe("u1");
expect(userMessageIdForToken(await fetchTimelineItems(client, imported.id), "follow up")).toBe(
"u2",
);
expect(await runtimeSessionId({ client, daemon }, session)).toBe(nativeId);
await client.refreshAgent(imported.id);
const restored = await fetchTimelineItems(client, imported.id);
expectTimeline(restored, expected);
const tools = restored.filter((item) => item.type === "tool_call");
expect(tools).toHaveLength(1);
expect(tools[0].detail).toEqual({
type: "read",
filePath: "readme.txt",
content: "saved tool output",
});
expect(
restored.filter((item) => item.type === "user_message").map((item) => item.messageId),
).toEqual(["u1", "u2"]);
const oldAnswer = restored.find(
(item) => item.type === "assistant_message" && item.text === "old answer",
);
if (oldAnswer?.type !== "assistant_message" || !oldAnswer.messageId)
throw new Error("Restored reply missing identity");
const beforeFork = await readFile(file, "utf8");
const fork = await client.buildAgentForkContext(imported.id, {
boundaryMessageId: oldAnswer.messageId,
});
expect(fork.attachment?.text).toContain("old question");
expect(fork.attachment?.text).toContain("old answer");
expect(fork.attachment?.text).not.toContain("follow up");
expect(fork.attachment?.text).not.toContain("abandoned question");
expect(await readFile(file, "utf8")).toBe(beforeFork);
const forkFile = path.join(root, "native-fork.jsonl");
const forkId = randomUUID();
await writeFile(
forkFile,
[
{ ...records[0], id: forkId },
...records.slice(1),
{
type: "custom",
id: "fork-leaf",
parentId: "a1",
timestamp,
customType: "paseo_rewind",
data: { targetId: "a1" },
},
]
.map((record) => JSON.stringify(record))
.join("\n") + "\n",
);
const importedFork = await client.importAgent({ provider: "pi", sessionId: forkFile, cwd });
expectTimeline(await fetchTimelineItems(client, importedFork.id), {
userTexts: ["old question"],
assistantCount: 1,
});
expect(await runtimeSessionId({ client, daemon }, { agentId: importedFork.id, cwd })).toBe(
forkId,
);
expect(await readFile(file, "utf8")).toBe(beforeFork);
await client.rewindAgent(imported.id, "u2", "conversation");
expectTimeline(await fetchTimelineItems(client, imported.id), {
userTexts: ["old question"],
assistantCount: 1,
});
await client.refreshAgent(imported.id);
expectTimeline(await fetchTimelineItems(client, imported.id), {
userTexts: ["old question"],
assistantCount: 1,
});
expect(await runtimeSessionId({ client, daemon }, session)).toBe(nativeId);
await client.rewindAgent(imported.id, "u1", "conversation");
await client.refreshAgent(imported.id);
expectTimeline(await fetchTimelineItems(client, imported.id), {
userTexts: [],
assistantCount: 0,
});
expect(await runtimeSessionId({ client, daemon }, session)).toBe(nativeId);
} finally {
await client.close();
await daemon.close();
await rm(root, { recursive: true, force: true });
}
}, 120_000);
+27
View File
@@ -187,6 +187,33 @@ describe("daemon web UI route module", () => {
expect(res.body).toContain("app");
});
test.each([
"/.well-known/oauth-protected-resource",
"/.well-known/oauth-protected-resource/mcp/agents",
"/.well-known/oauth-authorization-server",
"/.well-known/openid-configuration",
])("returns a JSON 404 instead of the SPA for missing %s", async (requestPath) => {
const app = createApp({ enabled: true, distDir, publicDir });
const res = await request(app, "GET", requestPath);
expect(res.status).toBe(404);
expect(res.headers["content-type"]).toBe("application/json; charset=utf-8");
expect(JSON.parse(res.body)).toEqual({ error: "Not found" });
});
test("serves well-known files that exist in the dist directory", async () => {
await mkdir(path.join(distDir, ".well-known"), { recursive: true });
await writeFile(path.join(distDir, ".well-known", "security.json"), '{"ok":true}');
const app = createApp({ enabled: true, distDir, publicDir });
const res = await request(app, "GET", "/.well-known/security.json");
expect(res.status).toBe(200);
expect(res.headers["content-type"]).toBe("application/json; charset=utf-8");
expect(res.body).toBe('{"ok":true}');
});
test("serves static assets", async () => {
const app = createApp({ enabled: true, distDir, publicDir });
+24 -2
View File
@@ -15,6 +15,13 @@ function isExcludedPath(requestPath: string): boolean {
return EXCLUDED_PATHS.has(requestPath);
}
// RFC 8615 well-known URIs are machine-read metadata. Clients such as MCP OAuth
// discovery (RFC 9728, RFC 8414) need a 404 for documents the daemon does not
// serve; the SPA's index.html with a 200 reads as malformed metadata.
function isWellKnownPath(requestPath: string): boolean {
return requestPath === "/.well-known" || requestPath.startsWith("/.well-known/");
}
const CONTENT_TYPES: Record<string, string> = {
".html": "text/html; charset=utf-8",
".js": "application/javascript; charset=utf-8",
@@ -71,7 +78,14 @@ interface ResolvedTarget {
isIndexHtml: boolean;
}
function resolveTargetFile(distDir: string, requestPath: string): ResolvedTarget | null {
interface ResolveTargetFileOptions {
distDir: string;
requestPath: string;
spaFallback: boolean;
}
function resolveTargetFile(options: ResolveTargetFileOptions): ResolvedTarget | null {
const { distDir, requestPath, spaFallback } = options;
const safePath = path.normalize(requestPath).replace(/^(\.\.[/\\])+/, "");
let filePath = path.join(distDir, safePath);
@@ -82,6 +96,9 @@ function resolveTargetFile(distDir: string, requestPath: string): ResolvedTarget
const finalStat = safeStat(filePath);
if (!finalStat?.isFile()) {
if (!spaFallback) {
return null;
}
filePath = path.join(distDir, "index.html");
const fallbackStat = safeStat(filePath);
if (!fallbackStat?.isFile()) {
@@ -190,8 +207,13 @@ interface ServeWebUiFileOptions {
function serveWebUiFile(options: ServeWebUiFileOptions): void {
const { distDir, requestPath, label, req, res } = options;
const target = resolveTargetFile(distDir, requestPath);
const wellKnown = isWellKnownPath(requestPath);
const target = resolveTargetFile({ distDir, requestPath, spaFallback: !wellKnown });
if (!target) {
if (wellKnown) {
res.status(404).json({ error: "Not found" });
return;
}
res.status(404).end();
return;
}
@@ -155,6 +155,7 @@ export interface WorkspaceGitRuntimeSnapshot {
repoRoot: string | null;
mainRepoRoot: string | null;
currentBranch: string | null;
headSha?: string;
remoteUrl: string | null;
isPaseoOwnedWorktree: boolean;
isDirty: boolean | null;
@@ -185,6 +186,7 @@ export interface WorkspaceGitRuntimeSnapshot {
state: string;
baseRefName: string;
headRefName: string;
headSha?: string;
isMerged: boolean;
isDraft?: boolean;
mergeable?: PullRequestMergeable;
@@ -320,6 +322,7 @@ export type WorkspaceGitSnapshotOptions =
| {
force: true;
includeForge?: boolean;
afterInFlight?: boolean;
reason: string;
};
@@ -3250,7 +3253,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
refreshWorktree: true,
includeForge: options?.includeForge ?? true,
reason: options?.reason ?? defaultReason,
queueIfBusy: false,
queueIfBusy: force && options?.afterInFlight === true,
movedRemoteRefs: new Set(),
};
}
@@ -3550,6 +3553,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
repoRoot: checkoutStatus.repoRoot,
mainRepoRoot: checkoutStatus.mainRepoRoot,
currentBranch: checkoutStatus.currentBranch,
headSha: facts.isGit ? facts.pullRequestLookupTarget?.headSha : undefined,
remoteUrl: checkoutStatus.remoteUrl,
isPaseoOwnedWorktree: checkoutStatus.isPaseoOwnedWorktree,
isDirty: refreshWorktree
@@ -128,6 +128,7 @@ export interface CurrentPullRequestStatus {
state: string;
baseRefName: string;
headRefName: string;
headSha?: string;
isMerged: boolean;
isDraft?: boolean;
mergeable: PullRequestMergeable;
@@ -2064,7 +2064,7 @@ describe("createGiteaService", () => {
]);
});
it("does not attach a stale Gitea-family PR after a same-name branch advances", async () => {
it("keeps a merged PR when its remote head advances beyond the checkout", async () => {
const stale = currentPullRequestApi({
number: 8,
state: "closed",
@@ -2086,7 +2086,7 @@ describe("createGiteaService", () => {
headRef: "dev",
headSha: "2222222222222222222222222222222222222222",
}),
).resolves.toBeNull();
).resolves.toMatchObject({ number: 8, state: "merged" });
});
it("returns null when no PR matches the current branch", async () => {
+10 -1
View File
@@ -162,6 +162,7 @@ const GiteaPrListItemSchema = z
updated: z.string().optional(),
labels: z.string().optional(),
ci: z.string().optional(),
headSha: z.string().optional(),
})
.passthrough();
@@ -1010,6 +1011,7 @@ function currentPullRequestApiToListItem(item: GiteaCurrentPullRequestApi): Gite
...(item.mergeable != null ? { mergeable: String(item.mergeable) } : {}),
base: item.base.ref,
head,
headSha: item.head.sha,
updated: item.updated_at,
labels: item.labels.map((label) => label.name).join(","),
};
@@ -1082,6 +1084,7 @@ function toCurrentPullRequestStatus(item: GiteaPrListItem): CurrentPullRequestSt
state,
baseRefName: item.base ?? "",
headRefName: stripHeadOwner(item.head),
...(item.headSha ? { headSha: item.headSha } : {}),
isMerged: state === "merged",
mergeable: mapGiteaMergeable(item),
checks: [],
@@ -1653,6 +1656,7 @@ export function createGiteaService(options: CreateGiteaServiceOptions = {}): For
input.headSha !== undefined &&
item.head.sha === input.headSha,
) ??
candidates[0] ??
null;
return match ? currentPullRequestApiToListItem(match) : null;
}
@@ -1718,7 +1722,12 @@ export function createGiteaService(options: CreateGiteaServiceOptions = {}): For
combined,
actionTasks,
);
return applyGiteaChecks(status, combined, actionTasks, actionRequiredRunIds);
return applyGiteaChecks(
{ ...status, headSha: pr.headSha },
combined,
actionTasks,
actionRequiredRunIds,
);
} catch {
return status;
}
@@ -23,7 +23,7 @@ const EXPECTED_GITHUB_FAST_POLL_MS = 20_000;
const EXPECTED_GITHUB_SLOW_POLL_MS = 120_000;
const EXPECTED_GITHUB_ERROR_BACKOFF_CAP_MS = 300_000;
const CURRENT_PR_STATUS_BASE_FIELDS =
"number,url,title,state,isDraft,baseRefName,headRefName,headRefOid,mergedAt,reviewDecision,mergeable,headRepositoryOwner";
"number,url,title,state,isDraft,baseRefName,headRefName,headRefOid,updatedAt,mergedAt,reviewDecision,mergeable,headRepositoryOwner";
const CURRENT_PR_STATUS_FIELDS = `${CURRENT_PR_STATUS_BASE_FIELDS},statusCheckRollup`;
const FORK_PR_QUERY = "state=all&per_page=100&head=forkOwner%3Afeature%2Ffork";
@@ -3219,7 +3219,7 @@ describe("ForgeService", () => {
});
});
it("keeps a merged PR only when headRefOid matches the checkout HEAD", async () => {
it("keeps a merged PR when headRefOid matches the checkout HEAD", async () => {
const headSha = "2222222222222222222222222222222222222222";
const runner = createRunner([
currentPullRequestJson({
@@ -3243,8 +3243,9 @@ describe("ForgeService", () => {
expect(status?.state).toBe("merged");
});
it("does not attach a stale merged PR after a same-name branch advances", async () => {
it("keeps a merged PR when its remote head advances beyond the checkout", async () => {
const stale = currentPullRequestJson({
headRepositoryOwner: { login: "parentOwner" },
state: "MERGED",
mergedAt: "2026-07-17T12:00:00Z",
headRefOid: "1111111111111111111111111111111111111111",
@@ -3265,12 +3266,105 @@ describe("ForgeService", () => {
headRef: "feature/fork",
headSha: "2222222222222222222222222222222222222222",
}),
).resolves.toBeNull();
).resolves.toMatchObject({ state: "merged" });
expect(runner.calls.some((call) => call.args[0] === "pr" && call.args[1] === "list")).toBe(
true,
);
});
it("prefers an exact terminal head and otherwise the most recently updated terminal PR", async () => {
const owner = { login: "forkOwner" };
const terminal = ({
number,
headRefOid,
updatedAt,
state,
}: {
number: number;
headRefOid: string;
updatedAt: string;
state: string;
}) =>
JSON.parse(
currentPullRequestJson({
number,
headRefOid,
updatedAt,
state,
mergedAt: state === "MERGED" ? updatedAt : null,
headRepositoryOwner: owner,
}),
);
const older = terminal({
number: 41,
headRefOid: "old",
updatedAt: "2026-07-01T00:00:00Z",
state: "MERGED",
});
const newer = terminal({
number: 42,
headRefOid: "new",
updatedAt: "2026-07-02T00:00:00Z",
state: "CLOSED",
});
for (const [headSha, expected] of [
["old", 41],
["other", 42],
] as const) {
const runner = createScriptedRunner([JSON.stringify(older), JSON.stringify([older, newer])]);
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
});
const status = await service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "feature/fork",
headRepositoryOwner: "forkOwner",
headSha,
});
expect(status?.number).toBe(expected);
}
});
it("prefers an exact merged PR over a closed draft", async () => {
const owner = { login: "forkOwner" };
const closedDraft = JSON.parse(
currentPullRequestJson({
number: 1,
state: "CLOSED",
isDraft: true,
headRefOid: "old",
updatedAt: "2026-07-01T00:00:00Z",
headRepositoryOwner: owner,
}),
);
const merged = JSON.parse(
currentPullRequestJson({
number: 2,
state: "MERGED",
mergedAt: "2026-07-02T00:00:00Z",
headRefOid: "exact",
updatedAt: "2026-07-02T00:00:00Z",
headRepositoryOwner: owner,
}),
);
const runner = createScriptedRunner([
JSON.stringify(closedDraft),
JSON.stringify([closedDraft, merged]),
]);
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
});
const status = await service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "feature/fork",
headRepositoryOwner: "forkOwner",
headSha: "exact",
});
expect(status).toMatchObject({ number: 2, state: "merged" });
});
it("prefers an open PR over an exact-SHA merged PR for the same head", async () => {
const checkoutSha = "2222222222222222222222222222222222222222";
const owner = { login: "forkOwner" };
@@ -3313,6 +3407,84 @@ describe("ForgeService", () => {
expect(status).toMatchObject({ number: 43, state: "open" });
});
it("keeps a merged PR targeting the fork itself", async () => {
const runner = createScriptedRunner([
currentPullRequestJson({
number: 7,
url: "https://github.com/alice/app/pull/7",
state: "MERGED",
mergedAt: "2026-07-17T12:00:00Z",
headRefOid: "checkout-head",
headRepositoryOwner: { login: "alice" },
}),
JSON.stringify({
owner: { login: "alice" },
name: "app",
parent: { owner: { login: "acme" }, name: "app" },
}),
"[]",
]);
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
});
const status = await service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "feature/fork",
headSha: "checkout-head",
});
expect(status).toMatchObject({ number: 7, repoOwner: "alice", state: "merged" });
});
it("compares the fork's terminal PR against parent PRs by state, head and update time", async () => {
const local = JSON.parse(
currentPullRequestJson({
number: 7,
url: "https://github.com/alice/app/pull/7",
state: "MERGED",
mergedAt: "2026-07-17T12:00:00Z",
updatedAt: "2026-07-17T12:00:00Z",
headRefOid: "local-head",
headRepositoryOwner: { login: "alice" },
}),
);
for (const [state, headSha, updatedAt, expected] of [
["OPEN", "other", "2026-07-16T12:00:00Z", 8],
["CLOSED", "other", "2026-07-18T12:00:00Z", 7],
["CLOSED", "other", "2026-07-18T12:00:00Z", 8],
] as const) {
const parent = {
...local,
number: 8,
url: "https://github.com/acme/app/pull/8",
state,
mergedAt: null,
headRefOid: headSha,
updatedAt,
};
const runner = createScriptedRunner([
JSON.stringify(local),
JSON.stringify({
owner: { login: "alice" },
name: "app",
parent: { owner: { login: "acme" }, name: "app" },
}),
JSON.stringify([{ number: 8 }]),
JSON.stringify(parent),
]);
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
});
const status = await service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "feature/fork",
headSha: state === "OPEN" || expected === 7 ? "local-head" : "neither",
});
expect(status?.number).toBe(expected);
}
});
it("resolves fork PR heads to the parent repository when gh pr view returns a stale branch match", async () => {
const runner = createScriptedRunner([
currentPullRequestJson({
+26 -20
View File
@@ -346,6 +346,7 @@ const CurrentPullRequestStatusSchema = z.object({
baseRefName: z.string().catch(""),
headRefName: z.string().catch(""),
headRefOid: z.string().optional(),
updatedAt: z.string().optional(),
mergedAt: z.string().nullable().optional(),
statusCheckRollup: z.unknown().optional(),
reviewDecision: z.unknown().optional(),
@@ -524,7 +525,7 @@ query PullRequestCheckoutTarget($owner: String!, $name: String!, $number: Int!)
}`;
const CURRENT_PR_STATUS_BASE_FIELDS =
"number,url,title,state,isDraft,baseRefName,headRefName,headRefOid,mergedAt,reviewDecision,mergeable,headRepositoryOwner";
"number,url,title,state,isDraft,baseRefName,headRefName,headRefOid,updatedAt,mergedAt,reviewDecision,mergeable,headRepositoryOwner";
const CURRENT_PR_STATUS_FIELDS = `${CURRENT_PR_STATUS_BASE_FIELDS},statusCheckRollup`;
const PULL_REQUEST_STATUS_FACTS_QUERY = `
@@ -640,6 +641,7 @@ const BatchPollPrNodeSchema = z.object({
baseRefName: z.string().catch(""),
headRefName: z.string().catch(""),
headRefOid: z.string().catch(""),
updatedAt: z.string().optional(),
mergedAt: z.string().nullable().optional(),
mergeable: PullRequestMergeableSchema.optional().default("UNKNOWN"),
reviewDecision: z.unknown().optional(),
@@ -744,6 +746,7 @@ fragment PaseoPollPullRequest on PullRequest {
baseRefName
headRefName
headRefOid
updatedAt
mergedAt
mergeable
reviewDecision
@@ -1118,6 +1121,7 @@ interface ResolvedPullRequestCandidate {
status: CurrentPullRequestStatus;
headSha?: string;
headRepositoryOwner?: string;
updatedAt?: string;
}
export function createGitHubService(options: CreateGitHubServiceOptions = {}): GitHubService {
@@ -2968,7 +2972,7 @@ async function resolveCurrentPullRequestView(options: {
headRepositoryOwner: options.headRepositoryOwner,
})
: null;
if (viewMatch) {
if (viewMatch?.status.state === "open") {
return viewMatch.status;
}
@@ -2980,12 +2984,13 @@ async function resolveCurrentPullRequestView(options: {
const parentOwner = repo?.parent?.owner?.login;
const parentName = repo?.parent?.name;
if (!forkOwner) {
return null;
return viewMatch?.status ?? null;
}
if (parentOwner && parentName) {
return resolveForkPullRequestView({
...options,
forkOwner,
viewMatch,
repo: `${parentOwner}/${parentName}`,
});
}
@@ -2998,7 +3003,7 @@ async function resolveCurrentPullRequestView(options: {
run: options.run,
});
const match = pickPullRequestCandidate({
candidates,
candidates: viewMatch ? [...candidates, viewMatch] : candidates,
headRef: options.headRef,
headSha: options.headSha,
headRepositoryOwner,
@@ -3019,6 +3024,7 @@ async function resolveForkPullRequestView(options: {
headRef: string;
headSha?: string;
forkOwner: string;
viewMatch: ResolvedPullRequestCandidate | null;
repo: string;
run: (args: string[], options: GitHubCommandRunnerOptions) => Promise<string>;
}): Promise<CurrentPullRequestStatus | null> {
@@ -3034,12 +3040,15 @@ async function resolveForkPullRequestView(options: {
),
);
const match = pickPullRequestCandidate({
candidates: candidates.filter(
(candidate): candidate is ResolvedPullRequestCandidate => candidate !== null,
),
candidates: [
...candidates.filter(
(candidate): candidate is ResolvedPullRequestCandidate =>
candidate !== null && candidate.headRepositoryOwner === options.forkOwner,
),
...(options.viewMatch ? [options.viewMatch] : []),
],
headRef: options.headRef,
headSha: options.headSha,
headRepositoryOwner: options.forkOwner,
});
return match?.status ?? null;
}
@@ -3337,6 +3346,7 @@ function toBatchCurrentPullRequestItem(
baseRefName: node.baseRefName,
headRefName: node.headRefName,
...(node.headRefOid ? { headRefOid: node.headRefOid } : {}),
updatedAt: node.updatedAt,
mergedAt: node.mergedAt ?? null,
...(statusCheckRollup ? { statusCheckRollup } : {}),
reviewDecision: node.reviewDecision,
@@ -3357,6 +3367,7 @@ function toCurrentPullRequestCandidate(
const headSha = item.headRefOid;
return {
status,
updatedAt: item.updatedAt,
...(headSha ? { headSha } : {}),
...(headRepositoryOwner ? { headRepositoryOwner } : {}),
};
@@ -3380,12 +3391,6 @@ function pickPullRequestCandidate(options: {
if (!isCandidateForHeadRef(candidate, options.headRef)) {
return false;
}
if (
candidate.status.state !== "open" &&
(!options.headSha || candidate.headSha !== options.headSha)
) {
return false;
}
if (!options.headRepositoryOwner) {
return true;
}
@@ -3411,17 +3416,17 @@ function comparePullRequestCandidatePreference(
if (leftExact !== rightExact) {
return leftExact ? -1 : 1;
}
return 0;
return (
(right.updatedAt ?? "").localeCompare(left.updatedAt ?? "") ||
(right.status.number ?? 0) - (left.status.number ?? 0)
);
}
function getPullRequestStateRank(status: CurrentPullRequestStatus): number {
if (status.state === "open" || status.isDraft) {
if (status.state === "open") {
return 0;
}
if (status.state === "merged") {
return 1;
}
return 2;
return 1;
}
type GitHubCloneProtocol = "https" | "ssh";
@@ -3969,6 +3974,7 @@ function toCurrentPullRequestStatus(
state,
baseRefName: item.baseRefName,
headRefName: item.headRefName || fallbackHeadRefName,
...(item.headRefOid ? { headSha: item.headRefOid } : {}),
isMerged: mergedAt !== null,
isDraft: item.isDraft ?? false,
mergeable: item.mergeable,
@@ -357,14 +357,16 @@ describe("createGitLabService", () => {
expect(calls[1]).toEqual(["mr", "view", "270", "-F", "json"]);
});
it("does not attach the latest historical merge request after a reused branch advances", async () => {
it("keeps a merged MR when its remote head advances beyond the checkout", async () => {
const stale = {
...OPEN_MR,
state: "merged",
source_branch: "dev",
sha: "1111111111111111111111111111111111111111",
};
const { service, calls } = makeService(() => ok(JSON.stringify([stale])));
const { service, calls } = makeService((args) =>
ok(JSON.stringify(args[1] === "list" ? [stale] : stale)),
);
await expect(
service.getCurrentPullRequestStatus({
@@ -372,8 +374,8 @@ describe("createGitLabService", () => {
headRef: "dev",
headSha: "2222222222222222222222222222222222222222",
}),
).resolves.toBeNull();
expect(calls).toEqual([currentMrListArgs("dev")]);
).resolves.toMatchObject({ state: "merged" });
expect(calls[0]).toEqual(currentMrListArgs("dev"));
});
it("looks up a numeric current branch through the source-branch list filter", async () => {
@@ -728,6 +728,7 @@ function toCurrentPullRequestStatus(
state: mapMergeRequestState(mr.state),
baseRefName: mr.target_branch,
headRefName: mr.source_branch,
...(mr.sha ? { headSha: mr.sha } : {}),
isMerged: mr.state === "merged" || mr.merged_at != null,
isDraft: mr.draft ?? mr.work_in_progress ?? false,
mergeable: mapMergeable(mr),
@@ -987,6 +988,7 @@ export function createGitLabService(options: CreateGitLabServiceOptions = {}): F
(mr) =>
mapMergeRequestState(mr.state) !== "open" && headSha !== undefined && mr.sha === headSha,
) ??
candidates[0] ??
null;
return match ? viewMergeRequest(cwd, String(match.iid)) : null;
}
@@ -4223,6 +4223,7 @@ export interface PullRequestStatus {
state: string;
baseRefName: string;
headRefName: string;
headSha?: string;
isMerged: boolean;
isDraft?: boolean;
mergeable?: PullRequestMergeable;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/website",
"version": "0.15.0-beta.1",
"version": "0.15.0",
"private": true,
"type": "module",
"scripts": {
@@ -3521,7 +3521,21 @@ diff --git a/node_modules/react-native-enriched-markdown/lib/module/normalizeMar
diff --git a/node_modules/react-native-enriched-markdown/src/web/styles.ts b/node_modules/react-native-enriched-markdown/src/web/styles.ts
--- a/node_modules/react-native-enriched-markdown/src/web/styles.ts
+++ b/node_modules/react-native-enriched-markdown/src/web/styles.ts
@@ -290,7 +290,7 @@
@@ -260,9 +260,12 @@ function videoStyle(style: MarkdownStyleInternal): CSSProperties {
function inlineImageStyle(style: MarkdownStyleInternal): CSSProperties {
const size = style.inlineImage.size;
+ // Inline images match the line height and keep their aspect ratio, so wide
+ // badges stay legible instead of shrinking into a square.
return {
- width: size,
+ width: 'auto',
height: size,
+ maxWidth: '100%',
verticalAlign: 'middle',
display: 'inline',
};
@@ -290,7 +293,7 @@ function codeStyle(style: MarkdownStyleInternal): CSSProperties {
const code = style.code;
return {
fontFamily: normalizeFontFamily(code.fontFamily),
@@ -3533,7 +3547,21 @@ diff --git a/node_modules/react-native-enriched-markdown/src/web/styles.ts b/nod
diff --git a/node_modules/react-native-enriched-markdown/lib/module/web/styles.js b/node_modules/react-native-enriched-markdown/lib/module/web/styles.js
--- a/node_modules/react-native-enriched-markdown/lib/module/web/styles.js
+++ b/node_modules/react-native-enriched-markdown/lib/module/web/styles.js
@@ -271,7 +271,7 @@
@@ -243,9 +243,12 @@ function videoStyle(style) {
}
function inlineImageStyle(style) {
const size = style.inlineImage.size;
+ // Inline images match the line height and keep their aspect ratio, so wide
+ // badges stay legible instead of shrinking into a square.
return {
- width: size,
+ width: 'auto',
height: size,
+ maxWidth: '100%',
verticalAlign: 'middle',
display: 'inline'
};
@@ -271,7 +274,7 @@ function codeStyle(style) {
const code = style.code;
return {
fontFamily: normalizeFontFamily(code.fontFamily),
@@ -3884,3 +3912,87 @@ diff --git a/node_modules/react-native-enriched-markdown/android/generated/jni/r
}
auto tmp_color = map.find("color");
if (tmp_color != map.end()) {
diff --git a/node_modules/react-native-enriched-markdown/src/web/utils.ts b/node_modules/react-native-enriched-markdown/src/web/utils.ts
--- a/node_modules/react-native-enriched-markdown/src/web/utils.ts
+++ b/node_modules/react-native-enriched-markdown/src/web/utils.ts
@@ -34,20 +34,32 @@ export function indexTaskItems(node: ASTNode, counter = { value: 0 }): void {
* Stamps Image nodes with `isInline` when they appear inside a paragraph
* that also contains other content (text, links, etc.).
* Matches native behavior: sole-child images are block-level, mixed are inline.
+ * A link wrapping only an image counts as an image, so `[![badge](x)](y) text`
+ * stamps the nested image inline while a lone `[![x](y)](z)` stays a block.
* Mutates the AST in-place (safe: called once on the freshly-parsed result).
*/
export function markInlineImages(node: ASTNode): void {
if (node.type === 'Paragraph' && node.children) {
- const hasNonImageChild = node.children.some(
- (child) => child.type !== 'Image'
- );
- if (hasNonImageChild) {
- for (const child of node.children) {
- if (child.type === 'Image') {
- child.attributes = { ...child.attributes, isInline: true };
- }
- }
+ if (!node.children.every(isImageLike)) {
+ node.children.forEach(stampInlineImages);
}
}
node.children?.forEach(markInlineImages);
}
+
+function isImageLike(node: ASTNode): boolean {
+ if (node.type === 'Image') return true;
+ return (
+ node.type === 'Link' &&
+ node.children?.length === 1 &&
+ isImageLike(node.children[0]!)
+ );
+}
+
+function stampInlineImages(node: ASTNode): void {
+ if (node.type === 'Image') {
+ node.attributes = { ...node.attributes, isInline: true };
+ return;
+ }
+ node.children?.forEach(stampInlineImages);
+}
diff --git a/node_modules/react-native-enriched-markdown/lib/module/web/utils.js b/node_modules/react-native-enriched-markdown/lib/module/web/utils.js
--- a/node_modules/react-native-enriched-markdown/lib/module/web/utils.js
+++ b/node_modules/react-native-enriched-markdown/lib/module/web/utils.js
@@ -40,18 +40,24 @@ export function indexTaskItems(node, counter = {
*/
export function markInlineImages(node) {
if (node.type === 'Paragraph' && node.children) {
- const hasNonImageChild = node.children.some(child => child.type !== 'Image');
- if (hasNonImageChild) {
- for (const child of node.children) {
- if (child.type === 'Image') {
- child.attributes = {
- ...child.attributes,
- isInline: true
- };
- }
- }
+ if (!node.children.every(isImageLike)) {
+ node.children.forEach(stampInlineImages);
}
}
node.children?.forEach(markInlineImages);
}
+function isImageLike(node) {
+ if (node.type === 'Image') return true;
+ return node.type === 'Link' && node.children?.length === 1 && isImageLike(node.children[0]);
+}
+function stampInlineImages(node) {
+ if (node.type === 'Image') {
+ node.attributes = {
+ ...node.attributes,
+ isInline: true
+ };
+ return;
+ }
+ node.children?.forEach(stampInlineImages);
+}
//# sourceMappingURL=utils.js.map
\ No newline at end of file
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/builtin-plugins",
"version": "0.15.0-beta.1",
"version": "0.15.0",
"private": true,
"type": "module",
"scripts": {