Compare commits

..
Author SHA1 Message Date
paseo-bot[bot] 72d9cef1a8 Test that a failed start keeps a waiting steer until its run is cleared 2026-10-06 04:41:59 +02:00
paseo-bot[bot] 03d23c577a Clear a failed start's run even when reporting the failure throws
A steer waiting on that start is released only when the run is cleared.
2026-10-06 04:36:28 +02:00
paseo-bot[bot] 0e2870c413 Wake a waiting steer only once the starting turn has an id or its run has ended
A failed start used to wake the waiting steer before its run was cleared,
leaving the later send to replace a run that had already failed.
2026-10-06 04:30:43 +02:00
paseo-bot[bot] 9f6c394a8e Deliver a steer message that arrives while the previous turn is still starting
A steer send that reached an agent before its previous turn had a turn id
found no turn to steer and replaced the starting turn, so the earlier
message never reached the provider. Steering now waits for the starting
turn to be accepted (or fail) and steers the turn it became.

Fixes #6186
2026-10-06 04:22:49 +02:00
paseo-bot[bot] 950e1f8f41 Keep an image's alt text and source when copying a selection from an assistant reply (#6181)
* fix(app): copy an assistant image as its Markdown, with alt text

Selecting across an image in an assistant reply copied the rendered img,
which has no alt text and, for data and workspace images, points at a
preview URL. The image frame now carries its Markdown source and alt text
the same way other Markdown elements declare their copy form.

Closes #6163

* fix(app): copy an image selected on its own as its Markdown

A selection inside the rendered image left the image's copy attributes
on the frame outside the range, so nothing was copied as Markdown.

* test(app): name the image-only selection in the copy spec

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-06 02:07:01 +02:00
paseo-bot[bot] 7a30305503 fix(opencode): agent goes idle after a v2 question is dismissed (#6177)
* fix(opencode): end the v2 turn when the user dismisses a question

OpenCode ends the execution when a question is dismissed but reports the
interruption with the reason "shutdown", which the turn tracker keeps open
waiting for a resume that never comes. The agent stayed running, and Stop,
reload and send were refused. A question dismissed from Paseo now lets that
interruption end the turn as canceled.

* test(opencode): cover the next message after a dismissed v2 question

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-06 01:06:55 +02:00
paseo-ai[bot] 3ad878b47f fix: update lockfile signatures and Nix hash [skip ci] 2026-10-05 23:02:27 +00:00
Mohamed Boudra 15d774d4a1 chore(release): cut 0.11.0-beta.5 2026-10-06 00:51:38 +02:00
Mohamed Boudra 5fd4072893 docs: changelog for 0.11.0-beta.5 2026-10-06 00:47:37 +02:00
Ruichen ZhouandMohamed Boudra 84054f8d72 fix(desktop): restore titlebar dragging after scrolling chat (#5233)
* fix(app): restore desktop screen header dragging

* fix(desktop): keep the titlebar draggable after scrolling chat

* fix(desktop): preserve global control exclusions without blocking chat headers

Keep programmatic focus containers out of the global interactive no-drag rule. Retain semantic controls everywhere, including window-owned sidebar toggles and negative-tabindex buttons. Verify both header rows and independently positioned controls with native input.

Co-authored-by: Ruichen Zhou <ruichenzhou@outlook.com>

* fix(desktop): isolate chat content from window drag regions

Electron includes scrolled interactive rectangles outside their viewport in window hit testing. Reset window regions at the chat viewport while preserving the global control exclusions for chrome and portals.

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-10-05 23:09:19 +01:00
paseo-bot[bot] f02cc16bb4 fix(app): close hover cards when the pointer leaves a clicked row (#6167)
* fix(app): close hover cards when the pointer leaves a clicked trigger

A hover card stayed open while any focus sat inside its trigger or content,
so clicking a sidebar workspace row (which focuses the row's button) pinned
the card after the pointer moved away, until Escape or a click elsewhere.
Only keyboard-visible focus now keeps the card open, which preserves the
keyboard entry path added with the shared hover card.

* fix(app): keep a hover card open when keyboard focus arrives during its close delay

Recheck keyboard focus when the close timer fires, and move the hover card e2e steps into sidebar helpers.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-05 23:06:34 +02:00
paseo-bot[bot]andNurshot 2f0cb2f54b Show the Codex speed bolt in yellow while Fast or Ultrafast is selected (#6152)
* fix(app): fill the speed icon for accelerated tiers

* fix(app): highlight the Codex speed bolt when Fast or Ultrafast is selected

Codex speed moved from the Fast toggle to a Speed selector, and the
composer only colored toggle features, so the bolt stayed muted for
every speed. A select feature is now active when its selected option is
not the default, and the Codex speed takes the same yellow highlight as
the Fast toggle, on the desktop toolbar and in the compact sheet.

Closes #5939

Co-authored-by: Nurshot <85260051+Nurshot@users.noreply.github.com>

* test(app): assert the Codex speed bolt is yellow on desktop and compact

---------

Co-authored-by: Nurshot <85260051+Nurshot@users.noreply.github.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-05 21:10:24 +01:00
paseo-bot[bot] 4ed13fadb6 fix(app): keep list numbers when a copy crosses a code block nested in an item (#6158)
A selection starting after an item's marker demotes that item to a paragraph.
When the item holds a code block, Turndown's HTML re-parse splits the paragraph
into several siblings, and counting siblings numbered the next item too high
("4. Done." instead of "2. Done."). Each item now carries its number before
the re-parse.

Closes #6140

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-05 21:52:51 +02:00
Callum WongandMohamed Boudra db303be3c2 fix(nix): ship resolved daemon dependencies and working terminals (#5523)
* fix(nix): ship @getpaseo/server's main export in the desktop closure

The desktop's runtime-paths.js locates the server package root with
require.resolve("@getpaseo/server"), which resolves through the exports
map to dist/server/server/exports.js. The desktop never loads that file,
so nft doesn't trace it, and the Linux Nix desktop build fails to start
the daemon with MODULE_NOT_FOUND.

* fix(nix): retain the server resolution anchor for CLI and desktop

Run the existing Nix daemon smoke through the built CLI so package-root resolution is exercised before readiness and HTTP checks.

Co-authored-by: Callum Wong <mail@callumwong.com>

* fix(nix): ship complete terminal runtimes

Resolve runtime dependency edges at their importing modules so package links and exports survive closure tracing regardless of npm hoisting. Use the native loader's selected files and verify terminal commands and hook CLI execution through the built package.

Co-authored-by: Callum Wong <mail@callumwong.com>

* test(desktop): make packaged CLI path expectations portable

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-10-05 19:25:48 +01:00
Mohamed Boudra 3e2d77723e fix(usage): scope Muse and agent logins, show every failed host login (#6156)
* fix(muse): show usage only for the agent account

Usage discovery ignored session scope and exposed all remembered accounts to unrelated agents. Resolve session accounts from their own launch environment while preserving global discovery.

* test(muse): own the usage scope regression in the plugin suite

* fix(usage): scope agent reports to their own logins

Account grouping also merged fetch inputs and cache entries across agent and host queries, hiding the agent login failure behind another harness. Preserve scoped login sets, collect every host failure with source-owned harness labels, and keep refresh results within their requesting view.

* test(usage): expect harness labels in discovered accounts
2026-10-05 18:41:54 +01:00
paseo-bot[bot] 18412604dd fix(app): copy only the code when a selection touches the block beside it (#6138)
* fix(app): copy a code block without its fence when the drag starts beside it

A drag that starts or ends in the gap beside a code block anchors the
selection at the edge of the neighbouring block. Only the code is
highlighted, but the range crossed the block boundary, so the copy took
the Markdown path and wrapped the code in its fence.

A selection now crosses a code block only when it selects content
outside it. Message rows the range touches without selecting anything
are dropped, and a selection whose content all lies inside one code
block copies as that code.

* fix(app): keep a selected image when copying it beside a code block

An image selected next to a code block has no text, so the selection
counted it as empty and copied only the code. Count images as selected
content so the copy keeps the image and the fence, as it did before.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-05 19:21:46 +02:00
Mohamed Boudra bc6ddbfa97 fix(muse): restore earlier messages after daemon restart (#6159)
MSP view cursors request only a suffix. A fresh provider session rebuilds an empty timeline, so it must resume without a cursor to recover earlier messages. Keep gap recovery cursors in memory and omit them from durable session state.
2026-10-05 17:47:04 +01:00
Mohamed Boudra 27a95a5dec feat(website): give the plugin directory a closing section for building and submitting plugins (#6160)
The directory header now keeps only "Submit a plugin", next to the search
box. Building and submitting move into a "Build your own plugin" section at
the end of the page, with "Read the docs" and "Submit a plugin" buttons.
The docs link is labelled "Read the docs" because that is what it does.

The site footer gets top margin on every page; it sat too close to content.
2026-10-05 17:36:13 +01:00
Mohamed Boudra 9760292650 fix(providers): launch plugin CLIs on Windows and tolerate Muse startup (#6151)
* fix(providers): launch plugin CLIs on Windows and tolerate Muse startup

Plugin providers bypassed the core Windows launcher and the plugin host exposed an empty server SDK module. Share the launcher through the SDK and bind it in the host. Give Muse initialization its own deadline because native startup traverses session history before answering MSP.

* fix(providers): terminate Windows launcher process trees

A command-script launcher leaves the CLI behind cmd.exe. Expose forced process-tree termination in the server SDK, use it for Muse and SDK ACP teardown, and bound Muse closing so inherited streams cannot leave callers waiting forever.

* test(muse): keep platform setup outside the teardown assertion

* refactor(plugin): expose process capabilities without quoting helpers

* fix(providers): close process trees on uncorrelated protocol errors
2026-10-05 17:10:02 +01:00
paseo-bot[bot]andMohamed Boudra d826d8109f fix(muse): load models whose effort tiers carry descriptions (#6021)
* fix(muse): load models whose effort tiers carry descriptions

Muse 1.4.2 sends reasoningEffortVariants as described tiers
({ tier, description? }) and the complete effort list on variants, as
the bundled MSP types declare. The catalog schema had the two shapes
swapped, so any model with a described tier failed the whole model/list
parse: the model picker stayed empty and agent creation failed.

Parse both fields as declared, offer the efforts from variants, and
show Muse's tier descriptions on the matching options.

* fix(muse): leave effort descriptions out until the pickers show them

* fix(muse): keep unused catalog metadata outside validation

Described effort tiers are presentation metadata, not selectable efforts. Validating their shape blocks both discovery and session startup even though Paseo never consumes them. Normalize the complete variants list at the wire boundary and cover session readiness and selecting max.

* fix(muse): align consumed session and event wire fields

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-10-05 16:12:12 +01:00
paseo-bot[bot]andMohamed Boudra 62f79e88bb fix(omp): a prompt OMP 18.3 rejects after acknowledging it now fails with OMP's error (#6115)
* fix(omp): fail the turn when OMP 18.3 rejects a prompt after acknowledging it

OMP 18.3 acknowledges a prompt, then reports a rejection such as a missing
API key only as a second failed response frame for the same request id. The
JSONL RPC transport dropped any response with no pending request, so the
turn stayed running with no error.

The transport now publishes a response nothing is waiting for like any
other frame, and the OMP runtime reads a failed prompt response as the
prompt_result error later OMP releases send.

* fix(omp): drop prompt results held for an OMP process that was replaced

Request ids restart with each OMP process, so a rejection the old process
reported after its prompt timed out could fail a prompt on the new process
with the same id. Move the real-OMP rejection case into the existing OMP
real E2E suite.

* test(omp): verify late-response routing at the transport boundary

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-10-05 15:27:26 +01:00
paseo-bot[bot] 9da676d709 Keep the daemon running when nobody reads its output (#6117)
* fix(server): keep the daemon running when its stdout reader is gone

`paseo daemon run` in the foreground hands its stdout and stderr to the
supervisor. When the program that started it closed its end, as
`paseo daemon run | head -1` does, the supervisor's next write failed
with EPIPE and nothing handled the error. The supervisor exited 1, the
worker lost its parent and exited too, and the CLI reported "Daemon
failed to start".

The supervisor now drops output nobody reads and keeps supervising the
worker. daemon.log still records everything. Other stdout and stderr
errors surface as before.

Closes #6114

* test(server): fail on unexpected daemon.log read errors and check stderr reaches the log

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-05 15:57:59 +02:00
paseo-bot[bot] dfb8a1a2bc fix(omp): an OMP run with a message Paseo does not render now finishes instead of staying running (#6112)
* fix(omp): finish the turn when OMP's run contains a developer message

OMP 18.5 injects hidden developer messages into a run, such as the
rule-violation reminder after a tool call matches a built-in rule. The
agent_end frame then failed schema validation and was dropped, so the
turn stayed running until it was cancelled.

Closes #6104

* fix(omp): parse every OMP message role so agent_end is never dropped

OMP's RPC AgentMessage also defines pythonExecution, hookMessage,
branchSummary, compactionSummary and fileMention. A prompt that
@-mentions a file puts a fileMention message in agent_end, the frame
failed validation and was dropped, and the turn stayed running.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-05 15:15:10 +02:00
paseo-bot[bot] 8ced059b4d fix(omp): fail the turn when OMP rejects a prompt before its agent runs (#6110)
OMP reports a prompt it rejects before running the agent, such as one
with no API key for the selected model, as a prompt_result with
agentInvoked false, status error, and the error message. Paseo read only
agentInvoked and ended the turn as completed, so the agent went idle with
no reply and no error. The turn now fails with OMP's message.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-05 12:03:18 +02:00
paseo-bot[bot] da9fae172d fix(claude): keep a subagent's card labeled after a daemon restart (#6100)
After a restart the parent's timeline is rebuilt from Claude Code's
transcript, and the Task/Agent call went through the generic tool
mapper, which has no subagent branch, so the card replayed as a bare
"Agent" with detail type "unknown". Live, the card is built from the
task protocol's declaration instead.

Replay already reads each Task call's type and description from the
parent transcript for the subagent track. Label the replayed cards
from those same facts, through the builder the live path uses.

The shared tool-call parser is left alone: a completed sub_agent card
from it would replace the action log a foreground child's card
carries live.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-05 10:10:42 +02:00
paseo-bot[bot] f24bb523e8 Exit CLI commands quietly when their output is no longer read (#6097)
* fix(cli): exit quietly when stdout's reader is gone

When the program that launched the CLI closed its end of stdout, the
output write failed with EPIPE and nothing handled the error. Node
printed an unhandled 'error' event and exited 1, and the desktop app's
CLI passthrough could raise Electron's main-process error dialog.

runCli now exits with code 0 on EPIPE from stdout. Other stdout errors
still surface as before.

Closes #5982

* fix(cli): keep running when stdout's reader is gone

Exiting on EPIPE ended a command's own lifecycle: `paseo daemon run`
writes its ready line and then supervises the daemon child, so exiting
there left the child running without its parent. Drop output nobody
reads instead, and let every command finish with its own exit code.

* fix(cli): stop following when stdout's reader is gone

With EPIPE ignored, `paseo agent logs --follow` and `paseo agent attach`
kept running after the program reading their output closed it, until
interrupted. Both now wait through one helper that stops on SIGINT,
SIGTERM, or EPIPE on stdout, so a closed reader stops them at their next
line of output the same way Ctrl+C does.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-05 09:18:15 +02:00
paseo-bot[bot] cf2b3f4bb3 Name a linked global install as the reason daemon self-update is refused (#6095)
* fix(server): name a linked global install as the reason self-update is refused

npm 7 and later print no link flag in `npm ls --json --long`, so a global
install made with `npm install -g <dir>` or `npm link` was never detected as
linked and self-update reported that the daemon was not running from the npm
global install. Detect the link from the install directory being a symlink,
the same way npm decides it.

Closes #6086

* fix(server): fail the self-update inspection when the install path can't be read

An unreadable install path now surfaces as the update error instead of being
treated as a normal install. Tests build real install directories for both
the linked and the normal case.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-05 08:04:00 +02:00
a50e14b69e fix(app): keep the centered new workspace form above the keyboard on tablets (#6093)
* fix(app): keep the centered new workspace form usable with the keyboard open

The centered tablet dock translated the whole form by the full keyboard
shift and reserved the keyboard twice to keep its top below the header.
On short tablet viewports such as an unfolded foldable, that left about
20pt for the setup fields and composer, which then overlapped.

Translate the centered form only by the part of the keyboard that
reaches it, and size it to fit above the keyboard.

Fixes #6012

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(app): rest the centered new workspace form on the keyboard

The keyboard shift excludes the bottom safe area, but the keyboard also
covers it. Measuring the centered form's clearance and capacity down to
the screen edge left the form one navigation-bar height under the
keyboard, hiding Import session on Android tablets.

Measure both above the bottom safe area so the form ends at the
keyboard's top edge.

Fixes #6012

Co-authored-by: Ivan Malison <IvanMalison@gmail.com>

---------

Co-authored-by: Ivan Malison <IvanMalison@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-05 07:21:35 +02:00
paseo-bot[bot]androyenheart 7b43f21940 fix(app): let Remote SSH connections authenticate to a password-protected daemon (#6081)
Co-authored-by: royenheart <royenheart@outlook.com>
2026-10-05 06:02:33 +02:00
paseo-ai[bot] 2f9a786061 fix: update lockfile signatures and Nix hash [skip ci] 2026-10-05 02:13:12 +00:00
Mohamed Boudra 6b00310f7b chore(release): cut 0.11.0-beta.4 2026-10-05 04:02:22 +02:00
Mohamed Boudra 981bb4c409 docs(changelog): fold the 0.11.0 betas into the 0.11.0-beta.4 entry 2026-10-05 03:58:32 +02:00
Mohamed Boudra 3ee4c49e3c docs(release): fold each beta into one changelog entry for the series
Each beta now rewrites the series' single entry to cover the previous stable
release to the release source, describing current behavior rather than adding
a per-beta entry. Promotion renames the entry to the stable heading.
2026-10-05 03:58:31 +02:00
Mohamed Boudra fbd1bc47e0 chore: normalize package-lock.json workspace dependency order 2026-10-05 03:58:28 +02:00
147 changed files with 4304 additions and 743 deletions
+19 -4
View File
@@ -36,7 +36,7 @@ jobs:
PASEO_HOME="$(mktemp -d)"
export PASEO_LISTEN=127.0.0.1:6767
WRAPPER_LOG="$PASEO_HOME/paseo-server-wrapper.log"
WRAPPER_LOG="$PASEO_HOME/paseo-cli.log"
cleanup() {
if [[ -n "${DAEMON_PID:-}" ]] && kill -0 "$DAEMON_PID" 2>/dev/null; then
@@ -47,7 +47,7 @@ jobs:
}
trap cleanup EXIT
PASEO_WEB_UI_ENABLED=true ./result/bin/paseo-server --no-relay >"$WRAPPER_LOG" 2>&1 &
PASEO_WEB_UI_ENABLED=true PASEO_RELAY_ENABLED=false ./result/bin/paseo daemon run >"$WRAPPER_LOG" 2>&1 &
DAEMON_PID=$!
deadline=$((SECONDS + 30))
@@ -57,7 +57,22 @@ jobs:
echo "$STATUS_JSON"
curl --fail --silent --show-error http://127.0.0.1:6767/ >"$PASEO_HOME/web-ui.html"
[[ -s "$PASEO_HOME/web-ui.html" ]]
exit 0
if ! TERMINAL_JSON="$(./result/bin/paseo terminal create --cwd "$PASEO_HOME" --name nix-smoke --json)"; then
break
fi
TERMINAL_ID="$(jq -er '.id' <<<"$TERMINAL_JSON")"
./result/bin/paseo terminal send-keys "$TERMINAL_ID" 'PATH= "$PASEO_HOOK_CLI" --version && printf "nix-terminal-%s\n" ok' Enter
deadline=$((SECONDS + 15))
while (( SECONDS < deadline )); do
if ./result/bin/paseo terminal capture "$TERMINAL_ID" --json \
| jq -e '.lines | any(. == "nix-terminal-ok")' >/dev/null; then
./result/bin/paseo terminal kill "$TERMINAL_ID"
exit 0
fi
sleep 1
done
echo "Nix terminal did not execute the command."
break
fi
if ! kill -0 "$DAEMON_PID" 2>/dev/null; then
@@ -72,7 +87,7 @@ jobs:
cat "$PASEO_HOME/daemon.log" 2>/dev/null || echo "<missing>"
echo "::endgroup::"
echo "::group::paseo-server stdout/stderr"
echo "::group::paseo daemon run stdout/stderr"
cat "$WRAPPER_LOG" 2>/dev/null || echo "<missing>"
echo "::endgroup::"
+39
View File
@@ -84,6 +84,45 @@
"max-nested-callbacks": ["error", { "max": 3 }]
},
"overrides": [
{
"files": [
"plugins/*-provider/server/**/*.ts",
"packages/plugin/src/server/acp-internal/**/*.ts"
],
"rules": {
"no-restricted-imports": [
"error",
{
"paths": [
{
"name": "node:child_process",
"importNames": [
"spawn",
"exec",
"execFile",
"spawnSync",
"execSync",
"execFileSync"
],
"message": "Use the plugin server process helpers so Windows command scripts and argv are handled consistently."
},
{
"name": "child_process",
"importNames": [
"spawn",
"exec",
"execFile",
"spawnSync",
"execSync",
"execFileSync"
],
"message": "Use the plugin server process helpers so Windows command scripts and argv are handled consistently."
}
]
}
]
}
},
{
"files": ["packages/app/src/**/*.{ts,tsx}"],
"rules": {
+79 -60
View File
@@ -1,63 +1,41 @@
# Changelog
## 0.11.0-beta.3 - 2026-10-02
### Added
- Added a pin button to Usage window rows, shown on hover and filled when the window is pinned ([#5876](https://github.com/getpaseo/paseo/pull/5876))
### Changed
- Changed usage source plugins to implement `discover()` and `fetch()` instead of `identify()` ([#5876](https://github.com/getpaseo/paseo/pull/5876))
### Fixed
- Fixed a Usage card disappearing when its login expired; the card now shows the expiry and the command that refreshes it ([#5876](https://github.com/getpaseo/paseo/pull/5876))
- Fixed a Usage card disappearing when its login was rejected; the card now shows the HTTP status ([#5876](https://github.com/getpaseo/paseo/pull/5876))
- Fixed an extra Usage card appearing when a usage source failed to discover accounts ([#5876](https://github.com/getpaseo/paseo/pull/5876))
## 0.11.0-beta.2 - 2026-10-01
### Added
- Added Usage discovery of ChatGPT logins from Codex CLI, OpenCode, Pi, and OMP ([#5844](https://github.com/getpaseo/paseo/pull/5844))
- Added Usage discovery of Claude logins from the Claude Code credential file, the macOS keychain, Pi, and OMP ([#5844](https://github.com/getpaseo/paseo/pull/5844))
### Changed
- Changed Usage to show one card per subscription account ([#5844](https://github.com/getpaseo/paseo/pull/5844))
- Changed Usage to try the account's next login when one is missing, expired, or signed into a different account ([#5844](https://github.com/getpaseo/paseo/pull/5844))
- Changed the sidebar Usage summary to stay hidden until a usage window has data ([#5844](https://github.com/getpaseo/paseo/pull/5844))
### Fixed
- Fixed built-in plugins, including usage sources, failing to start in packaged desktop apps ([#5844](https://github.com/getpaseo/paseo/pull/5844))
- Fixed each Paseo skill appearing twice in Codex's skill list ([#5827](https://github.com/getpaseo/paseo/pull/5827) by [@3ae3ae](https://github.com/3ae3ae))
- Fixed plugin client code failing to import npm packages that declare only `main` or `module` ([#5838](https://github.com/getpaseo/paseo/pull/5838))
## 0.11.0-beta.1 - 2026-10-01
## 0.11.0-beta.5 - 2026-10-06
### Added
- Added Muse Code as a provider, with model, approval mode, and reasoning effort controls ([#5719](https://github.com/getpaseo/paseo/pull/5719), [#5775](https://github.com/getpaseo/paseo/pull/5775) by [@millerben95](https://github.com/millerben95))
- Added Antigravity as a provider, driving the installed `agy` CLI in Full access ([#5714](https://github.com/getpaseo/paseo/pull/5714), [#5795](https://github.com/getpaseo/paseo/pull/5795))
- Added Usage to the sidebar footer, showing each account's percent or the windows you pin ([#5465](https://github.com/getpaseo/paseo/pull/5465), [#5685](https://github.com/getpaseo/paseo/pull/5685), [#5786](https://github.com/getpaseo/paseo/pull/5786), [#5805](https://github.com/getpaseo/paseo/pull/5805))
- Added a Usage screen that follows the account each session spends, including custom `CLAUDE_CONFIG_DIR` and `CODEX_HOME` homes and OpenCode Go ([#5465](https://github.com/getpaseo/paseo/pull/5465))
- Added Antigravity as a provider that drives the installed `agy` CLI, always in Full access ([#5714](https://github.com/getpaseo/paseo/pull/5714), [#5795](https://github.com/getpaseo/paseo/pull/5795))
- Added a Usage screen, opened from the sidebar footer, showing each subscription account's quota windows with Refresh ([#5465](https://github.com/getpaseo/paseo/pull/5465), [#5685](https://github.com/getpaseo/paseo/pull/5685), [#5786](https://github.com/getpaseo/paseo/pull/5786), [#5805](https://github.com/getpaseo/paseo/pull/5805), [#5921](https://github.com/getpaseo/paseo/pull/5921))
- Added an opt-in Usage summary to the sidebar footer that shows the windows you pin ([#5903](https://github.com/getpaseo/paseo/pull/5903), [#5975](https://github.com/getpaseo/paseo/pull/5975), [#6089](https://github.com/getpaseo/paseo/pull/6089))
- Added Usage discovery of ChatGPT logins from Codex CLI, OpenCode, Pi, and OMP ([#5844](https://github.com/getpaseo/paseo/pull/5844))
- Added Usage discovery of Claude logins from the Claude Code credential file, the macOS keychain, Pi, and OMP ([#5844](https://github.com/getpaseo/paseo/pull/5844))
- Added OpenCode Go to Usage ([#5465](https://github.com/getpaseo/paseo/pull/5465))
- Added the reason a Usage card is unavailable, such as an expired login with the command that refreshes it, listing each login's error when none works ([#5876](https://github.com/getpaseo/paseo/pull/5876), [#6156](https://github.com/getpaseo/paseo/pull/6156))
- Added mid-turn steering to OMP agents ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@n24q02m](https://github.com/n24q02m))
- Added Fast mode and an Auto thinking level to OMP ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@n24q02m](https://github.com/n24q02m), [@abhi-wan-kenobi](https://github.com/abhi-wan-kenobi))
- Added MCP servers to OMP schedules and Hub runs ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@omercnet](https://github.com/omercnet))
- Added Codex Fast and Ultrafast speeds from the Codex model catalog, with a lightning-bolt trigger on desktop ([#5708](https://github.com/getpaseo/paseo/pull/5708))
- Added a Content width setting for the chat and Markdown preview ([#5680](https://github.com/getpaseo/paseo/pull/5680))
- Added a confirmation before a pairing link connects to a new host or to a saved host whose key or relay changed ([#5753](https://github.com/getpaseo/paseo/pull/5753))
- Added provider option defaults in `config.json` under `agents.providers.<id>.options`, with per-agent overrides, for every provider ([#5780](https://github.com/getpaseo/paseo/pull/5780))
- Added command and environment overrides and availability diagnostics for plugin providers ([#5707](https://github.com/getpaseo/paseo/pull/5707))
- Added sidebar header and footer items and screens to the plugin SDK ([#5685](https://github.com/getpaseo/paseo/pull/5685))
- Added `activeTurnBehavior` to the SDK's `send()` and `run()` so a caller can steer instead of interrupt an active turn ([#5605](https://github.com/getpaseo/paseo/pull/5605) by [@wjxdy](https://github.com/wjxdy))
- Added a Content width setting for the maximum width of chat and Markdown files on wide screens ([#5680](https://github.com/getpaseo/paseo/pull/5680))
- Added `agents.providers.<id>.options` in `config.json` as default options for every provider, with per-agent overrides ([#5780](https://github.com/getpaseo/paseo/pull/5780))
- Added the terminal key bar and Paste to terminal panes on iPad and Android tablets ([#5712](https://github.com/getpaseo/paseo/pull/5712) by [@lardissone](https://github.com/lardissone))
- Added screens and sidebar header and footer items to the plugin SDK ([#5685](https://github.com/getpaseo/paseo/pull/5685))
- Added usage sources to the plugin SDK ([#5465](https://github.com/getpaseo/paseo/pull/5465), [#5876](https://github.com/getpaseo/paseo/pull/5876))
- Added `spawnProcess()`, `execCommand()`, and `terminateProcess()` to the plugin SDK for launching CLIs, including Windows `.cmd` and `.bat` scripts ([#6151](https://github.com/getpaseo/paseo/pull/6151))
- Added `client.playAudio()` to the plugin SDK for playing audio on every client ([#5976](https://github.com/getpaseo/paseo/pull/5976))
- Added an `agent.closed` plugin lifecycle event ([#5971](https://github.com/getpaseo/paseo/pull/5971) by [@ilteoood](https://github.com/ilteoood))
- Added command and environment overrides and availability diagnostics for plugin providers ([#5707](https://github.com/getpaseo/paseo/pull/5707))
### Changed
- Moved Import session into New workspace ([#5805](https://github.com/getpaseo/paseo/pull/5805))
- Changed the context window details to show usage for the account the agent runs under, including custom `CLAUDE_CONFIG_DIR` and `CODEX_HOME` homes ([#5465](https://github.com/getpaseo/paseo/pull/5465))
- Changed the context window details on desktop to a hover card with Refresh ([#5975](https://github.com/getpaseo/paseo/pull/5975))
- Changed the context window meter to show before an agent's first turn ([#6089](https://github.com/getpaseo/paseo/pull/6089))
- Changed Codex Fast to a Speed menu listing the Normal, Fast, and Ultrafast speeds each model offers, shown as a lightning bolt on desktop that turns yellow while Fast or Ultrafast is selected ([#5708](https://github.com/getpaseo/paseo/pull/5708), [#6152](https://github.com/getpaseo/paseo/pull/6152) by [@Nurshot](https://github.com/Nurshot))
- Changed Explorer tabs to work like workspace tabs, with a + menu, drag and drop, and Close for Files and Changes ([#5942](https://github.com/getpaseo/paseo/pull/5942))
- Changed Pi and OMP extension context to show as expandable tool rows instead of assistant text, and to hide Pi messages marked hidden ([#6090](https://github.com/getpaseo/paseo/pull/6090))
- Moved Import session from the sidebar footer to the New workspace screen ([#5805](https://github.com/getpaseo/paseo/pull/5805))
- Changed the sidebar workspace row to show last activity by default on new installs ([#6087](https://github.com/getpaseo/paseo/pull/6087))
- Changed compact sidebar footer buttons to 44×44 tap targets ([#6044](https://github.com/getpaseo/paseo/pull/6044))
- Changed new workspaces branched from `origin/main` or another remote branch to fetch that branch first ([#5788](https://github.com/getpaseo/paseo/pull/5788) by [@odoo-mvds](https://github.com/odoo-mvds))
- Changed Paseo's internal Claude agents for branch names, commit messages, and PR text to run with Claude hooks disabled ([#5750](https://github.com/getpaseo/paseo/pull/5750))
- Changed OMP approval mode changes to apply on the next idle relaunch, and to be refused during a turn ([#5550](https://github.com/getpaseo/paseo/pull/5550))
@@ -65,19 +43,36 @@
### Fixed
- Fixed Claude agents never showing Thought rows on Claude Opus 4.7 and later ([#4355](https://github.com/getpaseo/paseo/pull/4355) by [@ThePharmer](https://github.com/ThePharmer))
- Fixed a background Claude subagent's card showing as a bare "Task" that stayed running, or as "Agent" after a daemon restart ([#6067](https://github.com/getpaseo/paseo/pull/6067), [#6100](https://github.com/getpaseo/paseo/pull/6100))
- Fixed Claude Code terminals not showing as needing input when Claude is idle ([#6022](https://github.com/getpaseo/paseo/pull/6022))
- Fixed Claude usage calling a rate-limited login again before its retry time ([#6089](https://github.com/getpaseo/paseo/pull/6089))
- Fixed a Claude agent that entered plan mode on its own still showing and storing its previous mode ([#6074](https://github.com/getpaseo/paseo/pull/6074))
- Fixed a resumed agent returning to its creation-time mode after its mode was changed in the session ([#5140](https://github.com/getpaseo/paseo/pull/5140) by [@ThePlenkov](https://github.com/ThePlenkov))
- Fixed ACP plugin providers failing to launch a `.cmd` or `.bat` CLI on Windows ([#6151](https://github.com/getpaseo/paseo/pull/6151))
- Fixed ACP agents rejecting every following message after a turn failed with a permission request open ([#6045](https://github.com/getpaseo/paseo/pull/6045))
- Fixed the context window meter never appearing for ACP agents that report context usage ([#4848](https://github.com/getpaseo/paseo/pull/4848) by [@apple-ouyang](https://github.com/apple-ouyang))
- Fixed schedule runs and workspace metadata recording `[object Object]` when an ACP agent rejects a session setting ([#5765](https://github.com/getpaseo/paseo/pull/5765))
- Fixed OMP agents staying bound to a dead `omp` process after it crashed ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@viet-initx](https://github.com/viet-initx))
- Fixed OMP multi-select questions showing radio buttons and asking once per choice ([#5550](https://github.com/getpaseo/paseo/pull/5550))
- Fixed `/compact` on OMP reporting a failure after 60s while OMP kept compacting ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@Kh05ifr4nD](https://github.com/Kh05ifr4nD))
- Fixed `paseo import --provider omp` starting the agent on the default model instead of the session's ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@alloevil](https://github.com/alloevil))
- Fixed OMP-injected rows showing a `[custom_message]` prefix after a reload ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@jegork](https://github.com/jegork))
- Fixed OMP failed tools, Task, Wait, web search, fetch, and `xd://` rows showing raw objects or generic labels ([#5550](https://github.com/getpaseo/paseo/pull/5550))
- Fixed OMP tool rows showing raw objects or generic labels for failed tools, Task, Wait, web search, and fetch ([#5550](https://github.com/getpaseo/paseo/pull/5550))
- Fixed results of OMP `xd://` writes disappearing from the timeline ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@kasrakhosravi](https://github.com/kasrakhosravi))
- Fixed the composer showing the stale model after an OMP model fallback ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@gray-graff](https://github.com/gray-graff))
- Fixed opening an archived OMP agent starting an `omp` process that stayed running ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@thomasvan](https://github.com/thomasvan))
- Fixed an OMP turn staying running forever when the run contains a message Paseo does not show, such as an OMP rule reminder ([#6112](https://github.com/getpaseo/paseo/pull/6112))
- Fixed a prompt OMP rejects, such as for a missing API key, ending as a completed turn with no error ([#6110](https://github.com/getpaseo/paseo/pull/6110), [#6115](https://github.com/getpaseo/paseo/pull/6115))
- Fixed Pi and OMP sessions whose model was removed failing to import or reopen ([#6061](https://github.com/getpaseo/paseo/pull/6061))
- Fixed Pi agents on Pi 0.99 starting without Paseo's MCP servers when `pi-mcp-adapter` is not installed ([#5762](https://github.com/getpaseo/paseo/pull/5762) by [@ArietidsZ](https://github.com/ArietidsZ))
- Fixed a running Pi subagent showing an empty transcript until it finished ([#5755](https://github.com/getpaseo/paseo/pull/5755))
- Fixed a grouped Tintinweb pi-subagents completion marking only its first agent completed and leaving the rest on working ([#5826](https://github.com/getpaseo/paseo/pull/5826))
- Fixed OpenCode `ask` rules for `paseo_*` tools running the tool without a prompt ([#5801](https://github.com/getpaseo/paseo/pull/5801) by [@ymarcus93](https://github.com/ymarcus93))
- Fixed the first OpenCode prompt waiting for the event stream timeout when the OpenCode server was slow to become ready ([#5873](https://github.com/getpaseo/paseo/pull/5873) by [@axsuul](https://github.com/axsuul))
- Fixed the Nix packages failing to start the daemon and to open terminals ([#5523](https://github.com/getpaseo/paseo/pull/5523) by [@wongcallum](https://github.com/wongcallum))
- Fixed OpenCode v2 agents failing to start on the Nix packages ([#5988](https://github.com/getpaseo/paseo/pull/5988))
- Fixed each Paseo skill appearing twice in Codex's skill list ([#5827](https://github.com/getpaseo/paseo/pull/5827) by [@3ae3ae](https://github.com/3ae3ae))
- Fixed a failed Codex image generation leaving no row in the chat ([#5810](https://github.com/getpaseo/paseo/pull/5810) by [@3ae3ae](https://github.com/3ae3ae))
- Fixed a denied Codex command or file edit keeping a running spinner after the turn ended ([#5717](https://github.com/getpaseo/paseo/pull/5717) by [@3ae3ae](https://github.com/3ae3ae))
- Fixed rewinding a legacy Codex chat failing with `unknown variant thread/rollback` on Codex 0.156 and newer ([#5711](https://github.com/getpaseo/paseo/pull/5711) by [@3ae3ae](https://github.com/3ae3ae))
@@ -88,23 +83,47 @@
- Fixed Codex terminal rows reading only "Terminal" without the command on current Codex ([#5774](https://github.com/getpaseo/paseo/pull/5774))
- Fixed the composer showing the catalog default thinking level instead of the one the agent runs on ([#5804](https://github.com/getpaseo/paseo/pull/5804))
- Fixed the composer showing another model's label when the agent's model is missing from the loaded catalog ([#5796](https://github.com/getpaseo/paseo/pull/5796))
- Fixed schedule runs and workspace metadata recording `[object Object]` when an ACP agent rejects a session setting ([#5765](https://github.com/getpaseo/paseo/pull/5765))
- Fixed the chat jumping back by several screens when scrolling up through older history on web and desktop ([#5945](https://github.com/getpaseo/paseo/pull/5945), [#5970](https://github.com/getpaseo/paseo/pull/5970))
- Fixed images in chat shifting the text below them when they finish loading ([#5970](https://github.com/getpaseo/paseo/pull/5970))
- Fixed copying all the code in a chat code block sometimes including its ``` fence lines ([#6138](https://github.com/getpaseo/paseo/pull/6138))
- Fixed copying across a code block nested in a numbered list item renumbering the next item ([#6158](https://github.com/getpaseo/paseo/pull/6158))
- Fixed relative `path:line` file links in chat opening at the first line referenced for that file ([#5653](https://github.com/getpaseo/paseo/pull/5653) by [@3ae3ae](https://github.com/3ae3ae))
- Fixed PR comments with linked pictures showing raw HTML tags and losing the image link ([#5968](https://github.com/getpaseo/paseo/pull/5968))
- Fixed the desktop window not dragging from the workspace header after scrolling a long chat ([#5233](https://github.com/getpaseo/paseo/pull/5233) by [@Zhou-Ruichen](https://github.com/Zhou-Ruichen))
- Fixed opening an agent on a touch screen in the desktop layout raising the on-screen keyboard ([#5477](https://github.com/getpaseo/paseo/pull/5477) by [@dinhphieu](https://github.com/dinhphieu))
- Fixed Option+arrow, Cmd+arrow, and Cmd+Backspace line editing in the macOS terminal ([#3339](https://github.com/getpaseo/paseo/pull/3339) by [@gpambrozio](https://github.com/gpambrozio))
- Fixed the daemon freezing when an Add Project directory search reached a folder whose read never returns ([#6029](https://github.com/getpaseo/paseo/pull/6029), [#6041](https://github.com/getpaseo/paseo/pull/6041) by [@lardissone](https://github.com/lardissone))
- Fixed a new worktree workspace being archived when its `paseo.json` cannot be parsed ([#6036](https://github.com/getpaseo/paseo/pull/6036) by [@3ae3ae](https://github.com/3ae3ae))
- Fixed workspace script status not updating in other connected clients ([#6007](https://github.com/getpaseo/paseo/pull/6007) by [@swbiggart](https://github.com/swbiggart))
- Fixed agents and terminals inheriting the voice library path, which truncated Git Bash's PATH on Windows ([#5987](https://github.com/getpaseo/paseo/pull/5987) by [@hanh9898](https://github.com/hanh9898))
- Fixed macOS hosts showing as `UNKNOWN` when the network supplies no hostname ([#5922](https://github.com/getpaseo/paseo/pull/5922))
- Fixed `paseo daemon run` exiting with "Daemon failed to start" when the program that launched it stops reading its output ([#6117](https://github.com/getpaseo/paseo/pull/6117))
- Fixed CLI commands crashing with `EPIPE`, and the Windows desktop app showing an error dialog, when their output stops being read ([#6097](https://github.com/getpaseo/paseo/pull/6097))
- Fixed daemon self-update on a linked global install giving the wrong reason for refusing ([#6095](https://github.com/getpaseo/paseo/pull/6095))
- Fixed daemon self-update failing for an npm install under a prefix other than npm's global prefix ([#5649](https://github.com/getpaseo/paseo/pull/5649) by [@3ae3ae](https://github.com/3ae3ae))
- Fixed opening an archived agent whose worktree was removed showing "Workspace unavailable" when the workspace had no recorded branch ([#5694](https://github.com/getpaseo/paseo/pull/5694))
- Fixed a host's version staying stale on the host page after its daemon restarts ([#5351](https://github.com/getpaseo/paseo/pull/5351) by [@bashrusakh](https://github.com/bashrusakh))
- Fixed Remote SSH hosts failing to connect to a password-protected daemon, with a new Daemon password field ([#6081](https://github.com/getpaseo/paseo/pull/6081) by [@royenheart](https://github.com/royenheart))
- Fixed removing a host during its first connection attempt not stopping the connection ([#5722](https://github.com/getpaseo/paseo/pull/5722))
- Fixed a file upload started while reconnecting failing with `Connection changed during file upload` ([#5585](https://github.com/getpaseo/paseo/pull/5585))
- Fixed reloading a plugin adding `[System Error] Provider connection closed` to finished chats on its provider ([#5579](https://github.com/getpaseo/paseo/pull/5579))
- Fixed `paseo agent open` opening Desktop and exiting 0 for an agent that does not exist ([#5727](https://github.com/getpaseo/paseo/pull/5727))
- Fixed the desktop app launching a `daemon status` process every second when no local daemon runs ([#5636](https://github.com/getpaseo/paseo/pull/5636))
- Fixed browser annotation screenshots capturing the wrong region when the app is zoomed ([#5703](https://github.com/getpaseo/paseo/pull/5703))
- Fixed the context ring filling from three o'clock instead of twelve on web and desktop ([#5598](https://github.com/getpaseo/paseo/pull/5598))
- Fixed the `/` menu listing `/clear` and `/exit` in a draft before a project or model is chosen ([#5594](https://github.com/getpaseo/paseo/pull/5594) by [@colonelpanic8](https://github.com/colonelpanic8))
- Fixed plugin settings screens on desktop having no way back to the Plugins page ([#5620](https://github.com/getpaseo/paseo/pull/5620))
- Fixed the daemon logging `Agent not found` for every new agent started from a draft ([#5602](https://github.com/getpaseo/paseo/pull/5602))
- Fixed `daemon.log` saying nothing when a resumed Claude agent's transcript is missing or cannot be read ([#5818](https://github.com/getpaseo/paseo/pull/5818) by [@gabrielgiordan](https://github.com/gabrielgiordan))
- Fixed a dialog opened over another sheet on iPad failing to present ([#5772](https://github.com/getpaseo/paseo/pull/5772))
- Fixed the centered New workspace form collapsing and overlapping when the keyboard opens on tablets ([#6093](https://github.com/getpaseo/paseo/pull/6093) by [@colonelpanic8](https://github.com/colonelpanic8))
- Fixed the Android sidebar jumping back to the top when reopened after selecting a workspace ([#6058](https://github.com/getpaseo/paseo/pull/6058))
- Fixed tapping the context ring and long-pressing a workspace tab doing nothing on native tablets ([#5713](https://github.com/getpaseo/paseo/pull/5713) by [@JichenZhang](https://github.com/JichenZhang))
- Fixed iPad chat text keeping its narrow width after the Explorer closes ([#5716](https://github.com/getpaseo/paseo/pull/5716))
- Fixed iPad chat text keeping its old width after the pane resizes ([#5716](https://github.com/getpaseo/paseo/pull/5716))
- Fixed the context ring filling from three o'clock instead of twelve on web and desktop ([#5598](https://github.com/getpaseo/paseo/pull/5598))
- Fixed browser annotation screenshots capturing the wrong region when the app is zoomed ([#5703](https://github.com/getpaseo/paseo/pull/5703))
- Fixed the desktop app launching a `daemon status` process every second when no local daemon runs ([#5636](https://github.com/getpaseo/paseo/pull/5636))
- Fixed the `/` menu listing `/clear` and `/exit` in a draft before a project or model is chosen ([#5594](https://github.com/getpaseo/paseo/pull/5594) by [@colonelpanic8](https://github.com/colonelpanic8))
- Fixed `paseo agent delete --all` and `--cwd` leaving archived agents behind ([#6027](https://github.com/getpaseo/paseo/pull/6027) by [@3ae3ae](https://github.com/3ae3ae))
- Fixed `paseo agent` commands reporting a generic failure instead of `AGENT_NOT_FOUND` for an unknown agent ID ([#6053](https://github.com/getpaseo/paseo/pull/6053))
- Fixed `paseo agent open` opening Desktop and exiting 0 for an agent that does not exist ([#5727](https://github.com/getpaseo/paseo/pull/5727))
- Fixed `paseo restart` exiting with `RESTART_NOT_CONFIRMED` when provider checks take more than a second ([#6009](https://github.com/getpaseo/paseo/pull/6009))
- Fixed `paseo schedule update --no-max-runs` and `--no-expires-in` failing before reaching the daemon ([#5973](https://github.com/getpaseo/paseo/pull/5973) by [@Mar-garet](https://github.com/Mar-garet))
- Fixed `daemon.log` saying nothing when a resumed Claude agent's transcript is missing or cannot be read ([#5818](https://github.com/getpaseo/paseo/pull/5818) by [@gabrielgiordan](https://github.com/gabrielgiordan))
- Fixed reloading a plugin adding `[System Error] Provider connection closed` to finished chats on its provider ([#5579](https://github.com/getpaseo/paseo/pull/5579))
- Fixed plugin settings screens on desktop having no way back to the Plugins page ([#5620](https://github.com/getpaseo/paseo/pull/5620))
- Fixed plugin client code failing to import npm packages that declare only `main` or `module` ([#5838](https://github.com/getpaseo/paseo/pull/5838))
- Fixed the SDK's `send()` and `run()` rejecting `activeTurnBehavior` in TypeScript ([#5605](https://github.com/getpaseo/paseo/pull/5605) by [@wjxdy](https://github.com/wjxdy))
- Fixed bundling `@getpaseo/client` from npm failing with `Could not resolve "@getpaseo/relay/e2ee"` ([#5815](https://github.com/getpaseo/paseo/pull/5815))
## 0.10.3 - 2026-10-02
+12
View File
@@ -43,6 +43,18 @@ Run the shared browser profile fixture with:
PASEO_CAPTURE_HARNESS_GROUP=browser-profile npm run capture-harness --workspace=@getpaseo/desktop
```
On macOS, test native titlebar dragging after scrolling with:
```bash
PASEO_CAPTURE_HARNESS_GROUP=titlebar-drag npm run capture-harness --workspace=@getpaseo/desktop
```
This group needs Xcode Command Line Tools and Accessibility permission for the
terminal running it. It sends real mouse input to its test window and checks
window movement, including button clicks and a portal over the titlebar. Leave
the mouse idle during this group. It reads the app's production CSS and writes
screenshots and window bounds to the output directory.
The browser profile group runs two Electron processes in sequence. It verifies that each
renderer-side `did-attach` identity maps to the correct main-process guest, that two live
tabs share cookies and local storage through one persistent session, and that the data is
+3 -3
View File
@@ -248,7 +248,7 @@ dependency; shared types must not refer to React components, hooks, Node APIs, o
| Entry | Owns | May depend on |
| ---------------------------------------------------- | -------------------------------------------------------------------------- | ---------------------- |
| `@getpaseo/plugin` | Shared data, schemas, RPC/settings definitions, runtime-neutral helpers | Shared code only |
| `@getpaseo/plugin/server` | Server contribution/handler contexts and lifecycle contracts | Shared and server code |
| `@getpaseo/plugin/server` | Server contexts, lifecycle contracts, and CLI process launch | Shared and server code |
| `@getpaseo/plugin/server/provider`, `/server/acp` | Server provider contracts and adapters | Shared and server code |
| `@getpaseo/plugin/client` | Client contribution contexts, hooks, navigation, and UI contribution types | Shared and client code |
| `@getpaseo/plugin/client/react-native`, `/client/ui` | Host-provided UI components | Shared and client code |
@@ -488,9 +488,9 @@ SVG or URL.
## Usage sources
Register a usage source from `index.server.ts` with `server.registerUsageSource()`. Import `UsageSourceRegistration` and normalization helpers from `@getpaseo/plugin/server/usage`. The plugin owns account discovery and credential-store reads; the daemon owns account grouping, ordered login fallback, and the fetch cache. Scope discovery explicitly: global queries inspect machine stores; session queries inspect only the live harness's selected stores. The resolved launch environment crosses into the trusted, unsandboxed plugin subprocess for session discovery. Usage queries never run lifecycle hooks. Inputs are validated in the plugin process and remain daemon-side. `icon` uses the same sanitized SVG file rules as provider icons.
Register a usage source from `index.server.ts` with `server.registerUsageSource()`. Import `UsageSourceRegistration` and normalization helpers from `@getpaseo/plugin/server/usage`. The plugin owns account discovery, harness labels, and credential-store reads; the daemon owns account grouping and the fetch cache. Agent popovers use only the agent's own login. The host-wide Usage screen falls back across an account's logins; any success shows usage only, and all failures show every login's labeled error and remedy. Follow the [usage source discovery contract](../public-docs/plugins/reference.md#usage-sources). The resolved launch environment crosses into the trusted, unsandboxed plugin subprocess for session discovery. Usage queries never run lifecycle hooks. Inputs are validated in the plugin process and remain daemon-side. `icon` uses the same sanitized SVG file rules as provider icons.
The daemon calls discovery for `usage.list_reports`; the client gates this RPC on `server_info.features.usageSources`. The old `provider.usage.list` RPC maps discovered reports for older clients. See the [public usage source reference](../public-docs/plugins/reference.md#usage-sources) for the author contract and minimum version.
The daemon calls discovery for `usage.list_reports`; the client gates this RPC on `server_info.features.usageSources`. The old `provider.usage.list` RPC maps discovered reports for older clients.
## Contribute sidebar items
+3 -1
View File
@@ -10,7 +10,9 @@ Keep a bundled provider in `plugins/<id>/` and register it through
import rules belong to [plugins.md](plugins.md#built-in-plugins); the
[public provider guide](../public-docs/plugins/providers.md) covers the provider contract.
The plugin owns the CLI transport, session state, catalog, and capabilities. The daemon owns
The plugin owns the CLI transport, session state, catalog, and capabilities. Launch CLI transports
and probes through the [SDK process helpers](../public-docs/plugins/providers.md#launch-the-provider-cli),
which share Windows launcher handling with core providers. The daemon owns
executable resolution and applies `agents.providers.<provider-id>.command` and `env` before
connecting. Register the provider's icon with the plugin rather than adding it to the app's
provider icon map. You do not need a core manifest entry or provider factory.
+15 -17
View File
@@ -95,7 +95,7 @@ release push as the changelog and version commit.
There are two supported release paths:
1. **Direct stable release**: you are ready to ship the resolved release source to everyone immediately (default `origin/main`).
2. **Beta flow**: release candidates on the `beta` channel. Each beta carries its own changelog entry, publishes npm only on the explicit `beta` dist-tag, and stays behind the Stable/Beta switch on `/download`.
2. **Beta flow**: release candidates on the `beta` channel. Each beta folds into the series' single changelog entry, publishes npm only on the explicit `beta` dist-tag, and stays behind the Stable/Beta switch on `/download`.
Paseo has one linear release track even though npm dist-tags are independent
pointers. The npm invariant is:
@@ -194,7 +194,7 @@ npm run release:promote # Promote X.Y.Z-beta.N to stable X.Y.Z
- Desktop assets now come from the Electron package at `packages/desktop`
- Require the Linux artifact CI checks with both restricted and usable user namespaces to pass before publication; see [packaged desktop smoke](testing.md#packaged-desktop-smoke). Keep the installed-package and AppImage checks together.
- Beta releases use Electron's `beta` update channel. Users on the stable channel only receive stable releases; users on the beta channel receive beta releases and the final stable release when it is published.
- **Each beta carries its own changelog entry.** `Release Notes Sync` mirrors the matching `## X.Y.Z-beta.N` entry into that prerelease body. Promotion collapses every beta entry for the version into one final stable entry. See the Changelog policy section.
- **A beta series has one changelog entry.** Each beta folds into it and renames its heading to the new `## X.Y.Z-beta.N`, which `Release Notes Sync` mirrors into that prerelease body. Promotion renames it to the stable heading. See the Changelog policy section.
Use the beta path when you need to:
@@ -414,7 +414,7 @@ The GitHub Release body is populated automatically by the `Release Notes Sync` w
- Homebrew, the Play Store, the App Store, and `app.paseo.sh` have no beta. The Beta view drops those rows, and the whole Web section, rather than showing an inert "stable only" placeholder. When a surface gains a beta path — say a public TestFlight link — add its row back in `packages/website/src/routes/download.tsx`.
- The default download target only moves when you publish the final stable release tag like `v0.1.41`.
- The public `/changelog` page renders `CHANGELOG.md` as-is, so the in-flight `-beta.N` entry shows there once it lands on `main` — that's intended, it's where beta users check what's coming. Only the **default download target** stays pinned to the latest stable; the download links read GitHub's releases API, not the changelog, so a `-beta.N` heading on top never affects them.
- The download page's "What's new" link deep-links the **minor group** anchor (`/changelog#release-0.3`), not the exact entry: promotion collapses the beta entries into one stable entry, so the minor group remains the durable target. A version with no entry in the bundled changelog — a tag whose changelog commit hasn't redeployed the site yet — links the plain `/changelog` instead of a dead anchor.
- The download page's "What's new" link deep-links the **minor group** anchor (`/changelog#release-0.3`), not the exact entry: the beta entry is renamed on each beta and on promotion, so the minor group remains the durable target. A version with no entry in the bundled changelog — a tag whose changelog commit hasn't redeployed the site yet — links the plain `/changelog` instead of a dead anchor.
- The website itself is deployed by `Deploy Website` (Cloudflare Workers), which redeploys on the `release: published` event emitted when a stable draft is published and on pushes to `main` that touch `CHANGELOG.md` or `packages/website/**`. Its job condition excludes beta prereleases.
## Fixing a failed release build
@@ -521,17 +521,20 @@ No prefix (`v`), no extra text. `Release Notes Sync` matches the `## X.Y.Z` (or
## Changelog policy
- `CHANGELOG.md` includes stable releases and every entry in the current beta series.
- `CHANGELOG.md` holds stable releases and at most one entry for the current beta series.
- The first beta of a version inserts a top entry like `## 0.1.60-beta.1 - YYYY-MM-DD`.
- Each subsequent beta inserts a new top entry with the next beta number. Its notes cover the changes since the previous beta tag.
- Stable promotion replaces every beta entry for that version with one `## 0.1.60 - YYYY-MM-DD` entry.
- The promoted stable entry covers the full diff from the previous stable tag and collapses internal iterations across the beta series.
- Each subsequent beta rewrites that entry in place: the heading becomes the new beta number and date, and the body describes the full change from the previous stable release to this beta. Never add a second beta entry.
- The entry is a list of changes, not of PRs. When a later beta reverts, reworks, or follows up on something an earlier beta added, rewrite the bullet to the current behavior or delete it. A stable user never saw the earlier beta behavior, so fixes to it are not "Fixed" bullets.
- Anything that already shipped to stable through a patch release on an older line stays out of the beta entry.
- Stable promotion renames the entry to `## 0.1.60 - YYYY-MM-DD` after a final review.
Older prerelease bodies on GitHub keep the notes they were synced with; `Release Notes Sync` only writes the release whose tag matches the top entry.
## Changelog ownership
- **The agent running the release writes the changelog entry — beta or stable.** The release context and final wording stay with that agent.
- **Commit history is only an index of the changes. Never draft the changelog from commit subjects or diffs alone.** For every PR in the release range, read the full PR description and every issue it links to before deciding what changed, why users care, or how changes should be grouped. Use the implementation only to verify the resulting understanding.
- For the first beta or a direct stable release, draft from the previous stable tag to the release source. For later betas, draft from the previous beta tag to the release source. Promotion replaces the beta series with one entry drafted from the previous stable tag to the release source. Review the result against the changelog policy below, show it to the user, and wait for approval before committing it.
- Every entry, beta or stable, is drafted from the previous stable release to the release source. For a later beta, read the PRs since the previous beta tag, then rework the existing entry so each bullet still describes current behavior. Review the result against the changelog policy below, show it to the user, and wait for approval before committing it.
## Changelog wording
@@ -563,7 +566,7 @@ that changed, the failure that was fixed, or the capability that was added? If t
claims a general improvement, rewrite it with the concrete change.
- **Use the entry's release scope.** Include changes within the matching range in **Changelog scope**.
- **Collapse internal iterations within that scope.** Present a feature added and fixed in one range as working. A later beta can describe a fix to behavior delivered in an earlier beta; promotion folds the complete beta series into the final stable behavior.
- **Collapse internal iterations within that scope.** Present a feature added and fixed in one range as working.
- **Cut low-signal entries.** "Toolbar buttons have consistent sizing" is too granular. Combine small polish items or drop them.
## Changelog conciseness
@@ -631,12 +634,7 @@ Use `git diff <latest-release-tag>..<release-source-sha>` as the review input. T
Changelog scope follows the release being described:
- **First beta**: `previous stable tag → release source`
- **Later beta**: `previous beta tag → release source`
- **Direct stable release**: `previous stable tag → release source`
- **Stable promotion**: replace the full beta series with one entry covering `previous stable tag → release source`
Each beta entry records what its testers receive. Promotion produces the single stable record for the full jump from one stable version to the next.
Every entry covers `previous stable release → release source`: first beta, later beta, direct stable release, and promotion alike. The beta entry always reads as the stable entry would if the beta were promoted today.
## Completion checklist
@@ -644,7 +642,7 @@ Each beta entry records what its testers receive. Promotion produces the single
- [ ] The resolved release source is the intended commit (default `origin/main`) and its existing CI is green
- [ ] Every PR in the release range has been opened, and its full description and every linked issue have been read before drafting the changelog
- [ ] Add a new `CHANGELOG.md` entry for this beta (heading `## X.Y.Z-beta.N - YYYY-MM-DD`), review it against the changelog policy, get approval, and commit it before cutting the release
- [ ] Fold this beta into the series' single `CHANGELOG.md` entry (heading `## X.Y.Z-beta.N - YYYY-MM-DD`, previous stable → release source), review it against the changelog policy, get approval, and commit it before cutting the release
- [ ] The diff from the previous stable to the resolved release source is classified as patch or minor, with the target version and rationale approved
- [ ] Release preparation stayed local until the approved release command pushed the complete branch and tag
- [ ] `npm run release:beta:patch`, `npm run release:beta:minor`, or `npm run release:beta:next` completes successfully
@@ -667,7 +665,7 @@ Each beta entry records what its testers receive. Promotion produces the single
- [ ] Every PR in the release range has been opened, and its full description and every linked issue have been read before drafting the changelog
- [ ] Ensure the approved release inputs are committed locally and the git worktree is clean before running any release command
- [ ] Ensure local `npm run typecheck` passes on that exact commit before running any release command
- [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors). Promotion replaces every `## X.Y.Z-beta.N` entry in the series with one `## X.Y.Z - YYYY-MM-DD` entry covering the full release
- [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors). Promotion renames the series' `## X.Y.Z-beta.N` entry to `## X.Y.Z - YYYY-MM-DD` and re-checks it covers the full release
- [ ] Verify the changelog heading follows strict `## X.Y.Z - YYYY-MM-DD` format
- [ ] Release preparation stayed local until the approved release command pushed the complete branch and tag
- [ ] `npm run release:patch`, `npm run release:minor`, or `npm run release:promote` completes successfully
+3
View File
@@ -157,6 +157,9 @@ buildNpmPackage {
cp -a "$path" "$out/share/paseo-desktop/$path"
done < desktop-files.txt
# Shell hooks invoke the retained CLI bin directly, without a system Node.
patchShebangs --build "$out/share/paseo-desktop"
# Keep the same unpackaged monorepo layout expected by main.js.
cp package.json $out/share/paseo-desktop/
mkdir -p $out/share/paseo-desktop/packages/app
+1 -1
View File
@@ -1 +1 @@
sha256-VLcfftOTEgJ4METkulsfjJULlg+J1iJdsv72DlyiRJc=
sha256-mQOyGIvwVt45hldUnYuZTKJg4mLPm7d8jXXNlSQnExE=
+3
View File
@@ -120,6 +120,9 @@ buildNpmPackage rec {
cp -a "$path" "$out/lib/paseo/$path"
done < daemon-files.txt
# Shell hooks invoke the retained CLI bin directly, without a system Node.
patchShebangs --build "$out/lib/paseo"
# Root package.json lets node resolve the workspace layout when the
# CLI/server bin starts from $out.
cp package.json $out/lib/paseo/
+30 -30
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.11.0-beta.3",
"version": "0.11.0-beta.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.11.0-beta.3",
"version": "0.11.0-beta.5",
"hasInstallScript": true,
"license": "Apache-2.0",
"workspaces": [
@@ -36413,7 +36413,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.11.0-beta.3",
"version": "0.11.0-beta.5",
"dependencies": {
"@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4",
@@ -37496,12 +37496,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.11.0-beta.3",
"version": "0.11.0-beta.5",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.11.0-beta.3",
"@getpaseo/protocol": "0.11.0-beta.3",
"@getpaseo/server": "0.11.0-beta.3",
"@getpaseo/client": "0.11.0-beta.5",
"@getpaseo/protocol": "0.11.0-beta.5",
"@getpaseo/server": "0.11.0-beta.5",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -37748,10 +37748,10 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.11.0-beta.3",
"version": "0.11.0-beta.5",
"dependencies": {
"@getpaseo/protocol": "0.11.0-beta.3",
"@getpaseo/relay": "0.11.0-beta.3",
"@getpaseo/protocol": "0.11.0-beta.5",
"@getpaseo/relay": "0.11.0-beta.5",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -37762,7 +37762,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.11.0-beta.3",
"version": "0.11.0-beta.5",
"license": "Apache-2.0",
"dependencies": {
"@getpaseo/cli": "*",
@@ -38006,7 +38006,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.11.0-beta.3",
"version": "0.11.0-beta.5",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -38902,7 +38902,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.11.0-beta.3",
"version": "0.11.0-beta.5",
"dependencies": {
"@codemirror/language": "6.12.4",
"@codemirror/legacy-modes": "^6.5.3",
@@ -38931,14 +38931,14 @@
},
"packages/plugin": {
"name": "@getpaseo/plugin",
"version": "0.11.0-beta.3",
"version": "0.11.0-beta.5",
"dependencies": {
"@agentclientprotocol/sdk": "^1.4.0",
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.11.0-beta.3",
"@getpaseo/protocol": "0.11.0-beta.3",
"@getpaseo/client": "0.11.0-beta.5",
"@getpaseo/protocol": "0.11.0-beta.5",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.1.0",
@@ -38948,8 +38948,8 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.11.0-beta.3",
"@getpaseo/protocol": "0.11.0-beta.3",
"@getpaseo/client": "0.11.0-beta.5",
"@getpaseo/protocol": "0.11.0-beta.5",
"react": "~19.1.0",
"react-native": ">=0.81.5",
"zod": "^4.4.3"
@@ -38971,7 +38971,7 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.11.0-beta.3",
"version": "0.11.0-beta.5",
"dependencies": {
"semver": "^7.8.5",
"zod": "^4.4.3"
@@ -38998,7 +38998,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.11.0-beta.3",
"version": "0.11.0-beta.5",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -39216,16 +39216,16 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.11.0-beta.3",
"version": "0.11.0-beta.5",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.11.0-beta.3",
"@getpaseo/highlight": "0.11.0-beta.3",
"@getpaseo/plugin": "0.11.0-beta.3",
"@getpaseo/protocol": "0.11.0-beta.3",
"@getpaseo/relay": "0.11.0-beta.3",
"@getpaseo/client": "0.11.0-beta.5",
"@getpaseo/highlight": "0.11.0-beta.5",
"@getpaseo/plugin": "0.11.0-beta.5",
"@getpaseo/protocol": "0.11.0-beta.5",
"@getpaseo/relay": "0.11.0-beta.5",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
@@ -40146,11 +40146,12 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.11.0-beta.3",
"version": "0.11.0-beta.5",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
"@fontsource-variable/geist": "^5.3.0",
"@getpaseo/protocol": "*",
"@tanstack/react-router": "^1.170.16",
"@tanstack/react-start": "^1.168.26",
"framer-motion": "^12.35.2",
@@ -40165,8 +40166,7 @@
"remark-directive": "^4.0.0",
"remark-gfm": "^4.0.1",
"shiki": "^4.0.2",
"wrangler": "^4.75.0",
"@getpaseo/protocol": "*"
"wrangler": "^4.75.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.8",
@@ -41753,7 +41753,7 @@
},
"plugins": {
"name": "@getpaseo/builtin-plugins",
"version": "0.11.0-beta.3",
"version": "0.11.0-beta.5",
"dependencies": {
"@getpaseo/plugin": "*",
"zod": "^4.4.3"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.11.0-beta.3",
"version": "0.11.0-beta.5",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
@@ -120,6 +120,54 @@ async function selectAssistantElement(page: Page, selector: string): Promise<voi
});
}
/**
* Select an element from the end of the text before it. A drag that starts in the gap
* above a block anchors there, so nothing outside the element is highlighted.
*/
async function selectAssistantElementFromEndOf(
page: Page,
startText: string,
selector: string,
): Promise<void> {
await assistantMessageBlocks(page).evaluateAll(
(blocks, selected) => {
let startNode: Text | null = null;
for (const block of blocks) {
const walker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT);
while (walker.nextNode()) {
if (walker.currentNode.textContent?.endsWith(selected.startText)) {
startNode = walker.currentNode as Text;
}
}
}
const end = blocks.map((block) => block.querySelector(selected.selector)).find(Boolean);
if (!startNode || !end) {
throw new Error(`Could not find ${selected.startText} — ${selected.selector}`);
}
const range = document.createRange();
range.setStart(startNode, startNode.length);
range.setEnd(end, end.childNodes.length);
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
},
{ startText, selector },
);
}
/** A drag over an image alone selects it inside its rendered frame. */
async function selectAssistantImage(page: Page): Promise<void> {
await assistantMessageBlocks(page)
.locator("img")
.evaluate((element) => {
const range = document.createRange();
range.selectNode(element);
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
});
}
async function selectAssistantText(page: Page, text: string): Promise<void> {
await selectAssistantTextRange(page, text, text);
}
@@ -565,6 +613,19 @@ test("copying an assistant selection preserves Markdown structure and links", as
const completeCodeClipboard = await readRichClipboard(page);
expect(completeCodeClipboard.plainText).toBe('const answer = "yes";\n return answer;');
// Touching the block before it without selecting any of its text is still a
// selection inside the code.
await selectAssistantElementFromEndOf(
page,
"not a generated link.",
'[data-paseo-markdown-language="typescript"] [data-paseo-markdown-tag="code"]',
);
await copySelection(page);
expect((await readRichClipboard(page)).plainText).toBe(
'const answer = "yes";\n return answer;',
);
// Crossing the block boundary expresses an intent to carry its Markdown structure.
await selectAssistantAcrossCodeLines(page, "const", "After code.");
await copySelection(page);
@@ -601,3 +662,50 @@ test("copying an assistant selection preserves Markdown structure and links", as
await agent.cleanup();
}
});
test("copying a selection across an assistant image keeps its Markdown source and alt text", async ({
context,
page,
}) => {
const image =
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
const agent = await seedMockAgentWorkspace({
repoPrefix: "assistant-selection-copy-image-",
title: "Assistant selection copy image",
initialPrompt: "Render the image fixture.",
featureValues: {
mockAssistantResponse: [
"Before the image.",
"",
`![chart](${image})`,
"",
"After the image.",
].join("\n"),
},
});
try {
await allowRichClipboard(context);
await agent.client.waitForAgentUpsert(
agent.agentId,
(snapshot) => snapshot.status === "idle",
30_000,
);
await openAgentRoute(page, agent);
await expect(assistantMessageBlocks(page).locator("img")).toHaveCount(1);
await selectAssistantTextRange(page, "Before the image.", "After the image.");
await copySelection(page);
expect((await readRichClipboard(page)).plainText).toBe(
`Before the image.\n\n![chart](${image})\n\nAfter the image.`,
);
await selectAssistantImage(page);
await copySelection(page);
expect((await readRichClipboard(page)).plainText).toBe(`![chart](${image})`);
} finally {
await agent.cleanup();
}
});
@@ -26,6 +26,10 @@ test.use({
},
});
// Rendered light-theme colors: palette yellow 400 for Fast and Ultrafast, muted foreground for Normal.
const ACTIVE_SPEED_COLOR = "rgb(251, 191, 36)";
const NORMAL_SPEED_COLOR = "rgb(113, 113, 122)";
async function openSpeedSelector(page: Page): Promise<void> {
const speed = page.getByRole("button", { name: /^(Select speed|Speed: .+)$/ });
const features = page.getByRole("button", { name: "Open agent features", exact: true });
@@ -51,6 +55,13 @@ async function selectSpeed(page: Page, label: string): Promise<void> {
else await expect(sheetTrigger).toContainText(label);
}
async function expectSpeedIconColor(page: Page, color: string): Promise<void> {
const trigger = page
.getByRole("button", { name: /^(Speed: .+|Select speed)$/ })
.filter({ visible: true });
await expect(trigger.locator("svg").first()).toHaveCSS("stroke", color);
}
async function expectSpeedChoices(page: Page, choices: string[]): Promise<void> {
for (const choice of ["Normal", "Fast", "Ultrafast"]) {
const option = page.getByText(choice, { exact: true }).last();
@@ -100,15 +111,21 @@ for (const viewport of [
await page.screenshot({ path: testInfo.outputPath("desktop-normal-trigger.png") });
}
await openSpeedSelector(page);
await selectSpeed(page, "Fast");
await expectSpeedIconColor(page, ACTIVE_SPEED_COLOR);
await page.screenshot({ path: testInfo.outputPath(`${viewport.name}-fast-selected.png`) });
await openSpeedSelector(page);
await expectSpeedChoices(page, ["Normal", "Fast", "Ultrafast"]);
await page.screenshot({ path: testInfo.outputPath(`${viewport.name}-speed-options.png`) });
await selectSpeed(page, "Ultrafast");
await expectSpeedIconColor(page, ACTIVE_SPEED_COLOR);
await openSpeedSelector(page);
await expectSpeedChoices(page, ["Normal", "Fast", "Ultrafast"]);
await page.screenshot({
path: testInfo.outputPath(`${viewport.name}-ultrafast-selected.png`),
});
await selectSpeed(page, "Normal");
await expectSpeedIconColor(page, NORMAL_SPEED_COLOR);
if (viewport.name === "desktop") {
await switchAgentModel(agent.id, "gpt-6-sol");
await openSpeedSelector(page);
@@ -273,7 +273,7 @@ for (const theme of ["light", "dark"] as const) {
const card = contextWindowDetails(page);
await refreshUsageCard(card, "Claude");
await expect(card.getByText(LOGIN_EXPIRED)).toBeVisible();
expect(usage.refreshedReports()).toEqual([["claude:work"]]);
expect(usage.refreshedAgents()).toEqual([agent.agentId]);
});
await test.step("after a resume under another login, only that login shows", async () => {
@@ -321,7 +321,7 @@ for (const theme of ["light", "dark"] as const) {
await test.step("a host without usage reports shows only the context window", async () => {
await expectOnlyContextWindowWithoutUsage(page, usage, hoverContextWindowMeter, shot);
// One request per open that had usage; this one sends none.
expect(usage.agentRequests()).toHaveLength(5);
expect(usage.agentRequests()).toHaveLength(6);
});
});
@@ -351,7 +351,7 @@ for (const theme of ["light", "dark"] as const) {
const sheet = contextWindowDetails(page);
await refreshUsageCard(sheet, "Claude");
await expect(sheet.getByText(LOGIN_EXPIRED)).toBeVisible();
expect(usage.refreshedReports()).toEqual([["claude:work"]]);
expect(usage.refreshedAgents()).toEqual([agent.agentId]);
await shot("problem");
});
@@ -365,7 +365,7 @@ for (const theme of ["light", "dark"] as const) {
await test.step("a host without usage reports shows only the context window", async () => {
await expectOnlyContextWindowWithoutUsage(page, usage, pressContextWindowMeter, shot);
expect(usage.agentRequests()).toHaveLength(2);
expect(usage.agentRequests()).toHaveLength(3);
});
});
}
@@ -6,6 +6,11 @@ import {
closeMobileAgentSidebar,
expectMobileAgentSidebarHidden,
expectMobileAgentSidebarVisible,
expectWorkspaceHoverCardClosed,
expectWorkspaceHoverCardOpen,
expectWorkspaceHoverCardStaysOpen,
focusWorkspaceRowWithKeyboard,
movePointerAwayFromWorkspaceHoverCard,
openMobileAgentSidebar,
pinWorkspaceFromSidebar,
} from "../support/helpers/sidebar";
@@ -404,6 +409,47 @@ test.describe("Sidebar workspace list", () => {
}
});
test("workspace hover card closes when the pointer leaves a clicked row", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "sidebar-hover-click-leave-" });
try {
await gotoAppShell(page);
await waitForSidebarProject(page, path.basename(workspace.repoPath));
const row = await openWorkspaceFromSidebar(page, workspace.workspaceId);
await movePointerAwayFromWorkspaceHoverCard(page);
await expectWorkspaceHoverCardClosed(page);
await row.click();
await expect(row).toBeFocused();
await expectWorkspaceHoverCardOpen(page);
await movePointerAwayFromWorkspaceHoverCard(page);
await expectWorkspaceHoverCardClosed(page);
} finally {
await workspace.cleanup();
}
});
test("workspace hover card stays open for a keyboard-focused row", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "sidebar-hover-keyboard-" });
try {
await gotoAppShell(page);
await waitForSidebarProject(page, path.basename(workspace.repoPath));
await openWorkspaceFromSidebar(page, workspace.workspaceId);
await movePointerAwayFromWorkspaceHoverCard(page);
await expectWorkspaceHoverCardClosed(page);
await focusWorkspaceRowWithKeyboard(page, workspace.workspaceId);
await expectWorkspaceHoverCardOpen(page);
await movePointerAwayFromWorkspaceHoverCard(page);
await expectWorkspaceHoverCardStaysOpen(page);
await page.keyboard.press("Escape");
await expectWorkspaceHoverCardClosed(page);
} finally {
await workspace.cleanup();
}
});
test("marks a finished workspace unread until it is opened again", async ({ page }) => {
const workspace = await seedMockAgentWorkspace({
repoPrefix: "sidebar-mark-unread-",
+63 -2
View File
@@ -322,14 +322,14 @@ test("expired login refreshes to windows with visible pin toggles", async ({ pag
await gotoAppShell(page);
await openUsage(page);
await expect(
page.getByText("Login expired 1h ago. Run claude to refresh it.", { exact: true }),
page.getByText("Claude: Login expired 1h ago. Run claude to refresh it.", { exact: true }),
).toBeVisible();
await expect(page.getByText("Unavailable", { exact: true })).toBeVisible();
await qaScreenshot(page, "usage-expired-login");
await fixture.setReport(loginWindows);
await refreshLoginUsage(page);
await expect(
page.getByText("Login expired 1h ago. Run claude to refresh it.", { exact: true }),
page.getByText("Claude: Login expired 1h ago. Run claude to refresh it.", { exact: true }),
).toHaveCount(0);
await expectPinnedUsage(page, ["31% 5h", "54% wk"]);
const row = page.getByRole("checkbox", { name: /^Pin Claude Weekly, / });
@@ -474,3 +474,64 @@ async function captureSettingsState(
await qaScreenshot(page, name);
await testInfo.attach(name, { body: await page.screenshot(), contentType: "image/png" });
}
test("an account with all logins failing shows each harness and remedy, then only usage when one succeeds", async ({
page,
}) => {
const fixture = await installLoginUsage([
{ harness: "Codex", report: { status: "error", error: "Usage API returned 500. Try again." } },
{
harness: "OpenCode",
report: {
status: "unavailable",
problem: { kind: "rejected", status: 401, refreshedBy: "opencode" },
},
},
{
harness: "Pi",
report: { status: "unavailable", problem: { kind: "no_quota", detail: "No active plan" } },
},
{
harness: "OMP",
report: {
status: "unavailable",
problem: { kind: "rejected", status: 403, refreshedBy: "omp" },
},
},
]);
try {
await gotoAppShell(page);
await openUsage(page);
await expectLoginErrors(page, [
"Codex: Usage API returned 500. Try again.",
"OpenCode: Login rejected (HTTP 401). Run opencode to refresh it.",
"Pi: No active plan",
"OMP: Login rejected (HTTP 403). Run omp to refresh it.",
]);
await fixture.setReport([
{
harness: "Codex",
report: { status: "error", error: "Usage API returned 500. Try again." },
},
{
harness: "OpenCode",
report: { status: "available", windows: [{ id: "weekly", label: "Weekly", usedPct: 42 }] },
},
]);
await refreshLoginUsage(page);
await expectLoginUsageOnly(page, "42%");
} finally {
await fixture.cleanup();
}
});
async function expectLoginErrors(page: Page, lines: string[]) {
const card = page.getByTestId("usage-report-login-journey:account");
await expect(card.getByTestId("usage-login-error")).toHaveText(lines.join("\n"));
}
async function expectLoginUsageOnly(page: Page, percentage: string) {
const card = page.getByTestId("usage-report-login-journey:account");
await expect(card.getByText(percentage, { exact: true })).toBeVisible();
await expect(card.getByTestId("usage-login-error")).toHaveCount(0);
}
@@ -134,8 +134,8 @@ export interface AgentUsageScript {
stopReportingUsage(): void;
/** The agent of each agent usage request, in order. */
agentRequests(): string[];
/** Each report Refresh, by report ID. */
refreshedReports(): string[][];
/** Each scoped agent Refresh, by agent ID. */
refreshedAgents(): string[];
}
/**
@@ -162,8 +162,10 @@ export async function scriptAgentUsage(page: Page): Promise<AgentUsageScript> {
supported = false;
},
agentRequests: () => usage.listRequests().flatMap((request) => request.agentId ?? []),
refreshedReports: () =>
usage.listRequests().flatMap((request) => (request.reportIds ? [request.reportIds] : [])),
refreshedAgents: () =>
usage
.listRequests()
.flatMap((request) => (request.forceRefresh && request.agentId ? [request.agentId] : [])),
};
}
@@ -53,6 +53,37 @@ export async function showWorkspaceHoverCard(page: Page, workspaceId: string): P
await expect(page.getByTestId("workspace-hover-card")).toBeVisible();
}
export async function movePointerAwayFromWorkspaceHoverCard(page: Page): Promise<void> {
// Two moves, so the pointer always moves even when it is already parked here.
await page.mouse.move(1200, 200, { steps: 5 });
await page.mouse.move(1200, 400, { steps: 5 });
}
export async function focusWorkspaceRowWithKeyboard(
page: Page,
workspaceId: string,
): Promise<void> {
const row = page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspaceId}`);
await row.focus();
await page.keyboard.press("Shift+Tab");
await page.keyboard.press("Tab");
await expect(row).toBeFocused();
}
export async function expectWorkspaceHoverCardOpen(page: Page): Promise<void> {
await expect(page.getByTestId("workspace-hover-card")).toBeVisible();
}
export async function expectWorkspaceHoverCardStaysOpen(page: Page): Promise<void> {
// Longer than the hover card's close grace period.
await page.waitForTimeout(300);
await expectWorkspaceHoverCardOpen(page);
}
export async function expectWorkspaceHoverCardClosed(page: Page): Promise<void> {
await expect(page.getByTestId("workspace-hover-card")).toHaveCount(0);
}
export async function closeWorkspaceContextMenu(page: Page, workspaceId: string): Promise<void> {
const workspaceKey = `${getServerId()}:${workspaceId}`;
await page.getByTestId(`sidebar-workspace-context-menu-${workspaceKey}-backdrop`).click();
@@ -8,12 +8,15 @@ import { connectNewWorkspaceDaemonClient } from "./new-workspace";
import { pluginRequirements } from "./plugin-fixture";
/** A real store-backed plugin on the worker's isolated daemon. */
export async function installLoginUsage(initialReport: UsageReport) {
type LoginReports = Array<{ harness: string; report: UsageReport }>;
export async function installLoginUsage(initialReport: UsageReport | LoginReports) {
const directory = await mkdtemp(path.join(tmpdir(), "usage-login-journey-"));
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previous = await client.getDaemonConfig();
const store = path.join(directory, "report.json");
const setReport = (report: UsageReport) => writeFile(store, JSON.stringify(report));
const setReport = (report: UsageReport | LoginReports) =>
writeFile(store, JSON.stringify(report));
const cleanup = async () => {
try {
await client.removePlugin("login-journey");
@@ -35,9 +38,13 @@ export async function installLoginUsage(initialReport: UsageReport) {
import {readFile} from "node:fs/promises";
import {z} from "zod";
export default function contribute(server) {
server.registerUsageSource({id: "login-journey", label: "Claude", input: z.object({}),
discover: async () => [{key: "account", input: {}}],
fetch: async () => JSON.parse(await readFile(${JSON.stringify(store)}, "utf8")),
const read = async () => {
const value = JSON.parse(await readFile(${JSON.stringify(store)}, "utf8"));
return Array.isArray(value) ? value : [{harness: "Claude", report: value}];
};
server.registerUsageSource({id: "login-journey", label: "Claude", input: z.object({index: z.number()}),
discover: async () => (await read()).map((login, index) => ({key: "account", harness: login.harness, input: {index}})),
fetch: async ({index}) => (await read())[index].report,
});
return () => {};
}`,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.11.0-beta.3",
"version": "0.11.0-beta.5",
"private": true,
"main": "index.ts",
"scripts": {
+9
View File
@@ -70,6 +70,15 @@
-webkit-app-region: no-drag !important;
}
/* Electron does not clip app-region rectangles to scroll viewports.
Content-only viewports emit no window regions, including from their
controls. Visible controls here use normal DOM hit testing.
Use initial: Blink parses the explicit value none as no-drag. */
[data-window-content],
[data-window-content] * {
-webkit-app-region: initial !important;
}
/* Suppress the browser's default focus outline — it appears on mouse
clicks and looks out of place against our themed surfaces. Keep a
themed ring for keyboard users via :focus-visible. */
@@ -2,6 +2,7 @@ import type { AgentMode } from "@getpaseo/protocol/agent-types";
export const PLAN_MODE_FEATURE_ID = "plan_mode";
export const FAST_MODE_FEATURE_ID = "fast_mode";
export const SPEED_FEATURE_ID = "service_tier";
export function isPlanningAgentMode(mode: Pick<AgentMode, "id" | "colorTier">): boolean {
return mode.colorTier === "planning" || mode.id === "plan" || mode.id.endsWith("#plan");
@@ -1241,7 +1241,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
!liveAuxiliary;
return (
<div style={viewportStyle}>
<div style={viewportStyle} data-window-content>
<div
ref={handleScrollContainerRef}
data-testid="agent-chat-scroll"
@@ -320,6 +320,32 @@ describe("assistant selection copy ranges", () => {
).toBe("6. Second bullet text");
});
it("keeps the next number when a drag crosses a code block nested in an item", () => {
const message = mountFixture();
const list = fixtureElement(message, '[data-paseo-markdown-tag="ul"]');
list.outerHTML = [
'<div data-paseo-markdown-tag="ol" data-paseo-markdown-list-start="1">',
'<div data-paseo-markdown-tag="li">',
'<div data-paseo-markdown-ignore="true" data-paseo-markdown-list-marker="true">1.</div>',
"<div><div><span>Install it:</span></div>",
'<div data-paseo-markdown-tag="pre" data-paseo-markdown-language="sh">',
'<div data-paseo-markdown-tag="code"><span>brew install foo</span></div>',
'<button data-paseo-markdown-ignore="true">Copy</button>',
"</div></div>",
"</div>",
'<div data-paseo-markdown-tag="li">',
'<div data-paseo-markdown-ignore="true" data-paseo-markdown-list-marker="true">2.</div>',
"<div><div><span>Done.</span></div></div>",
"</div>",
"</div>",
].join("");
const content = copyAcross(message, "Install it:", "Done.");
expect(content?.plainText).toBe("Install it:\n\n```sh\nbrew install foo\n```\n\n2. Done.");
expect(content?.html).toContain("<div>2. Done.</div>");
});
it("retains nested markers when a drag includes the complete outer item", () => {
const message = mountFixture();
const firstItem = fixtureElement(message, '[data-paseo-markdown-tag="li"]');
@@ -385,6 +411,20 @@ describe("assistant selection copy ranges", () => {
expect(content?.html).not.toContain("<pre>");
});
it("copies an image as its Markdown when the selection stays inside the rendered image", () => {
const message = mountFixture();
const frame = document.createElement("div");
frame.setAttribute("data-paseo-markdown-tag", "img");
frame.setAttribute("data-paseo-markdown-src", "https://example.test/chart.png");
frame.setAttribute("data-paseo-markdown-alt", "chart");
frame.innerHTML = '<div><div><img src="blob:https://example.test/preview" alt=""></div></div>';
message.prepend(frame);
expect(copiedMarkdown(selectNodeContents(fixtureElement(frame, "img").parentElement!))).toBe(
"![chart](https://example.test/chart.png)",
);
});
it("copies code without a fence when every character is selected inside the block", () => {
const message = mountFixture();
const blockCode = fixtureElement(
@@ -405,10 +445,19 @@ describe("assistant selection copy ranges", () => {
* Turndown, so those cases need a fixture shaped like the real thing.
*/
function highlightedFixture(language: string | null): string {
return [
'<div data-testid="assistant-message">',
'<div data-paseo-markdown-tag="p"><span>Before the block.</span></div>',
highlightedCodeBlock(language),
'<div data-paseo-markdown-tag="p"><span>After the block.</span></div>',
"</div>",
].join("");
}
function highlightedCodeBlock(language: string | null): string {
const languageAttribute =
language === null ? "" : ` data-paseo-markdown-language="${escapeAttribute(language)}"`;
return [
'<div data-testid="assistant-message">',
`<div data-paseo-markdown-tag="pre"${languageAttribute}>`,
'<span data-paseo-markdown-tag="code">',
"<span>const</span><span> answer</span><span> = 1;</span>",
@@ -419,8 +468,6 @@ function highlightedFixture(language: string | null): string {
"</span>",
'<div data-paseo-markdown-ignore="true"><span>Copy</span></div>',
"</div>",
'<div data-paseo-markdown-tag="p"><span>After the block.</span></div>',
"</div>",
].join("");
}
@@ -598,6 +645,65 @@ describe("assistant selection copy inside highlighted code", () => {
);
});
// A drag that starts or ends in the gap beside a block anchors the selection at the
// edge of the neighbouring block. Nothing outside the code is highlighted or selected.
it("copies code without a fence when the selection only touches the end of the block before it", () => {
const message = mountHighlighted();
const before = tokenText(message, "Before the block.");
const content = copyBetween(
[before, before.length],
[tokenText(message, " doThing();"), 14],
);
expect(content?.plainText).toBe("const answer = 1;\n if (answer) {\n doThing();");
});
it("copies code without a fence when the selection only touches the start of the block after it", () => {
const message = mountHighlighted();
const content = copyBetween(
[tokenText(message, "const"), 0],
[tokenText(message, "After the block."), 0],
);
expect(content?.plainText).toBe("const answer = 1;\n if (answer) {\n doThing();");
});
it("copies code without a fence when the selection only touches the row before the block", () => {
const transcript = mountTranscript([{ messageId: "message-1", blocks: ["Run this:", "code"] }]);
const codeRow = transcript.querySelectorAll('[data-testid="assistant-message"]')[1]!;
codeRow.innerHTML = highlightedCodeBlock("typescript");
const before = tokenText(transcript, "Run this:");
const content = copyBetween(
[before, before.length],
[tokenText(transcript, " doThing();"), 14],
);
expect(content?.plainText).toBe("const answer = 1;\n if (answer) {\n doThing();");
});
it("keeps an image selected beside the code in the copy", () => {
const message = mountHighlighted();
const image = document.createElement("div");
image.setAttribute("data-paseo-markdown-tag", "p");
// The rendered `img` shows a preview URL with no alt text; the frame carries the Markdown.
image.innerHTML =
'<div data-paseo-markdown-tag="img" data-paseo-markdown-src="https://example.test/chart.png" data-paseo-markdown-alt="chart"><img src="blob:https://example.test/preview" alt=""></div>';
message.querySelector('[data-paseo-markdown-tag="pre"]')!.before(image);
const before = tokenText(message, "Before the block.");
const content = copyBetween(
[before, before.length],
[tokenText(message, " doThing();"), 14],
);
expect(content?.plainText).toBe(
"![chart](https://example.test/chart.png)\n\n```typescript\nconst answer = 1;\n if (answer) {\n doThing();\n```",
);
});
it("retains the fence when a complete code block is selected across its boundary", () => {
const message = mountHighlighted();
@@ -7,10 +7,12 @@ import {
} from "@/utils/rich-clipboard";
import {
MARKDOWN_COPY_ALIGN_ATTRIBUTE,
MARKDOWN_COPY_ALT_ATTRIBUTE,
MARKDOWN_COPY_IGNORE_ATTRIBUTE,
MARKDOWN_COPY_LANGUAGE_ATTRIBUTE,
MARKDOWN_COPY_LIST_MARKER_ATTRIBUTE,
MARKDOWN_COPY_LIST_START_ATTRIBUTE,
MARKDOWN_COPY_SRC_ATTRIBUTE,
MARKDOWN_COPY_TAG_ATTRIBUTE,
MARKDOWN_COPY_UNWRAP_ATTRIBUTE,
TRAILING_CODE_LINE_BREAKS,
@@ -53,9 +55,7 @@ turndown.addRule("compactListItem", {
if (parent?.nodeName !== "OL") {
return `${options.bulletListMarker} ${item}\n`;
}
const start = Number(parent.getAttribute("start") ?? 1);
const index = Array.from(parent.children).indexOf(node);
return `${start + index}. ${item}\n`;
return `${node.getAttribute("value")}. ${item}\n`;
},
});
@@ -67,8 +67,8 @@ export function createAssistantSelectionClipboardContent(
}
const range = selection.getRangeAt(0);
const parts = selectedMessageParts(range);
if (!parts) {
const parts = selectedMessageParts(range)?.filter((part) => selectsContent(part.range));
if (!parts?.length) {
return null;
}
@@ -103,10 +103,12 @@ export function createAssistantSelectionClipboardContent(
*
* A selection contained inside code always copies as code, even when it contains every
* character. A selection that crosses the code boundary stays on the Markdown path so
* a complete block retains its fence.
* a complete block retains its fence. Crossing means selecting content outside the code:
* a drag that starts or ends in the gap beside a block anchors at the edge of the
* neighbouring block, which selects nothing there.
*/
function createPartialCodeContent(range: Range, message: Element): MarkdownClipboardContent | null {
const region = closestCodeRegion(range.commonAncestorContainer, message);
const region = selectedCodeRegion(range, message);
if (!region) {
return null;
}
@@ -130,6 +132,18 @@ function createPartialCodeContent(range: Range, message: Element): MarkdownClipb
});
}
function selectedCodeRegion(range: Range, message: Element): Element | null {
const edgeRegions = [range.startContainer, range.endContainer].map((node) =>
closestCodeRegion(node, message),
);
return (
edgeRegions.find(
// Measure against the whole block, so its own hover Copy button is not outside it.
(edge) => edge && selectsNothingOutside(range, edge.closest(CODE_BLOCK_SELECTOR) ?? edge),
) ?? null
);
}
function closestCodeRegion(node: Node, message: Element): Element | null {
const element = node instanceof Element ? node : node.parentElement;
const region = element?.closest(CODE_REGION_SELECTOR) ?? null;
@@ -281,7 +295,8 @@ function shouldPreserveSemanticElement(range: Range, element: Element): boolean
return false;
}
const tag = element.getAttribute(MARKDOWN_COPY_TAG_ATTRIBUTE);
if (tag === "p" || isTableStructure(tag)) {
// An image has no part to select, so touching it selects all of it.
if (tag === "p" || tag === "img" || isTableStructure(tag)) {
return true;
}
const isSelectableSemantic = tag !== null && tag !== "li" && tag !== "ol" && tag !== "ul";
@@ -384,6 +399,31 @@ function hasSelectedAllContents(range: Range, element: Element, includeIgnored =
return true;
}
function selectsContent(range: Range): boolean {
return hasMarkdownContent(range.cloneContents(), true);
}
function selectsNothingOutside(range: Range, element: Element): boolean {
const contents = document.createRange();
contents.selectNodeContents(element);
if (range.compareBoundaryPoints(Range.START_TO_START, contents) < 0) {
const before = range.cloneRange();
before.setEnd(contents.startContainer, contents.startOffset);
if (selectsContent(before)) {
return false;
}
}
if (range.compareBoundaryPoints(Range.END_TO_END, contents) > 0) {
const after = range.cloneRange();
after.setStart(contents.endContainer, contents.endOffset);
if (selectsContent(after)) {
return false;
}
}
return true;
}
function hasMarkdownContent(fragment: DocumentFragment, includeIgnored: boolean): boolean {
if (!includeIgnored) {
for (const ignored of fragment.querySelectorAll(`[${MARKDOWN_COPY_IGNORE_ATTRIBUTE}]`)) {
@@ -393,8 +433,9 @@ function hasMarkdownContent(fragment: DocumentFragment, includeIgnored: boolean)
if (fragment.textContent) {
return true;
}
const visibleVoidSelector = ["br", "hr"]
const visibleVoidSelector = ["br", "hr", "img"]
.map((tag) => `[${MARKDOWN_COPY_TAG_ATTRIBUTE}="${tag}"]`)
.concat("img")
.join(",");
return Boolean(fragment.querySelector(visibleVoidSelector));
}
@@ -488,14 +529,19 @@ function restoreMarkdownElements(container: HTMLElement): void {
continue;
}
const semanticElement = document.createElement(tagName);
if (tagName !== "br") {
if (tagName !== "br" && tagName !== "img") {
semanticElement.append(...element.childNodes);
}
if (tagName === "img") {
semanticElement.setAttribute("src", element.getAttribute(MARKDOWN_COPY_SRC_ATTRIBUTE) ?? "");
semanticElement.setAttribute("alt", element.getAttribute(MARKDOWN_COPY_ALT_ATTRIBUTE) ?? "");
}
if (tagName === "ol") {
const start = element.getAttribute(MARKDOWN_COPY_LIST_START_ATTRIBUTE);
if (start) {
semanticElement.setAttribute("start", start);
}
numberOrderedListItems(semanticElement, Number(start ?? 1));
}
if (tagName === "pre") {
const language = element.getAttribute(MARKDOWN_COPY_LANGUAGE_ATTRIBUTE);
@@ -528,6 +574,20 @@ function restoreMarkdownElements(container: HTMLElement): void {
}
}
/**
* Each item carries its number before Turndown re-parses the HTML. Until then every
* child of the list is one item, including a partly selected item demoted to a `p`.
* The parser splits a `p` that holds a code block into several siblings, so counting
* siblings afterwards numbers the next item too high.
*/
function numberOrderedListItems(list: Element, start: number): void {
Array.from(list.children).forEach((child, index) => {
if (child.tagName === "LI") {
child.setAttribute("value", String(start + index));
}
});
}
function unwrapIncompleteTables(container: HTMLElement): void {
for (const table of container.querySelectorAll("table")) {
if (hasUsableTableColumnContract(table)) {
@@ -5,6 +5,8 @@ export const MARKDOWN_COPY_UNWRAP_ATTRIBUTE = "data-paseo-markdown-unwrap";
export const MARKDOWN_COPY_LIST_START_ATTRIBUTE = "data-paseo-markdown-list-start";
export const MARKDOWN_COPY_LANGUAGE_ATTRIBUTE = "data-paseo-markdown-language";
export const MARKDOWN_COPY_ALIGN_ATTRIBUTE = "data-paseo-markdown-align";
export const MARKDOWN_COPY_SRC_ATTRIBUTE = "data-paseo-markdown-src";
export const MARKDOWN_COPY_ALT_ATTRIBUTE = "data-paseo-markdown-alt";
/**
* Trailing line breaks, with any indentation that followed the last one.
@@ -63,6 +65,18 @@ export function markdownCopyCodeBlockDataSet(language: string | null | undefined
} as const;
}
/**
* An image copies as the Markdown it came from. The rendered `img` points at a preview
* URL for data and workspace images, and carries no alt text of its own.
*/
export function markdownCopyImageDataSet(source: string, alt: string | undefined) {
return {
paseoMarkdownTag: "img",
paseoMarkdownSrc: source,
...(alt ? { paseoMarkdownAlt: alt } : {}),
} as const;
}
export function markdownCopyTableCellDataSet(tag: "td" | "th", style: unknown) {
const alignment =
typeof style === "string"
@@ -1,13 +1,25 @@
import { useCallback, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Text, View } from "react-native";
import {
Pressable,
Text,
View,
type PressableStateCallbackType,
type StyleProp,
type ViewStyle,
} from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { Terminal } from "lucide-react-native";
import { Eye, EyeOff, Terminal } from "lucide-react-native";
import { parseSshTransportUri } from "@getpaseo/protocol/ssh-transport";
import type { HostProfile } from "@/types/host-connection";
import { useHostMutations, useHosts } from "@/runtime/host-runtime";
import { Button } from "@/components/ui/button";
import { Field, FormTextInput } from "@/components/ui/form-field";
import {
CONTROL_HEIGHTS,
createControlGeometry,
resolveControlInteractionStyles,
} from "@/components/ui/control-geometry";
import type { EditingTextInputHandle } from "@/components/ui/text-input";
import { useIsCompactFormFactor } from "@/constants/layout";
import { DaemonConnectionTestError } from "@/utils/test-daemon-connection";
@@ -15,18 +27,64 @@ import { AdaptiveModalSheet, type SheetHeader } from "./adaptive-modal-sheet";
const FLEX_ONE_STYLE = { flex: 1 } as const;
const ThemedTerminal = withUnistyles(Terminal);
const ThemedEye = withUnistyles(Eye, (theme) => ({ color: theme.colors.foregroundMuted }));
const ThemedEyeOff = withUnistyles(EyeOff, (theme) => ({ color: theme.colors.foregroundMuted }));
const styles = StyleSheet.create((theme) => ({
helper: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
actions: {
flexDirection: "row",
gap: theme.spacing[3],
marginTop: theme.spacing[2],
},
}));
const styles = StyleSheet.create((theme) => {
const geometry = createControlGeometry(theme);
return {
helper: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
passwordRow: {
flexDirection: "row",
alignItems: "center",
alignSelf: "stretch",
width: "100%",
gap: theme.spacing[2],
},
passwordInputWrap: {
flex: 1,
minWidth: 0,
},
// Boxed adornment next to the field: same height and radius as the field
// chrome of the active form size, and the shared interaction phases.
iconButtonSm: {
minHeight: CONTROL_HEIGHTS.compact,
width: CONTROL_HEIGHTS.compact,
alignItems: "center",
justifyContent: "center",
borderRadius: theme.borderRadius.md,
backgroundColor: theme.colors.surface2,
},
iconButtonMd: {
minHeight: CONTROL_HEIGHTS.field,
width: CONTROL_HEIGHTS.field,
alignItems: "center",
justifyContent: "center",
borderRadius: theme.borderRadius.lg,
backgroundColor: theme.colors.surface2,
},
controlRest: {
...geometry.controlRest,
},
controlHover: {
...geometry.controlHover,
},
controlActive: {
...geometry.controlActive,
},
controlDisabled: {
...geometry.controlDisabled,
},
actions: {
flexDirection: "row",
gap: theme.spacing[3],
marginTop: theme.spacing[2],
},
};
});
export interface AddRemoteSshHostModalProps {
visible: boolean;
@@ -51,14 +109,20 @@ export function AddRemoteSshHostModal({
const isCompact = useIsCompactFormFactor();
const { probeAndUpsertRemoteSshConnection } = useHostMutations();
const targetRef = useRef("");
const passwordRef = useRef("");
const inputRef = useRef<EditingTextInputHandle>(null);
const passwordInputRef = useRef<EditingTextInputHandle>(null);
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
const header = useMemo<SheetHeader>(() => ({ title: t("pairing.remoteSsh.title") }), [t]);
const clear = useCallback(() => {
targetRef.current = "";
passwordRef.current = "";
inputRef.current?.replaceText("");
passwordInputRef.current?.replaceText("");
setIsPasswordVisible(false);
setErrorMessage("");
}, []);
@@ -94,7 +158,10 @@ export function AddRemoteSshHostModal({
try {
setIsSaving(true);
setErrorMessage("");
result = await probeAndUpsertRemoteSshConnection(target);
result = await probeAndUpsertRemoteSshConnection({
...target,
password: passwordRef.current === "" ? undefined : passwordRef.current,
});
} catch (error) {
const message =
error instanceof DaemonConnectionTestError
@@ -116,6 +183,34 @@ export function AddRemoteSshHostModal({
const handleTargetChange = useCallback((value: string) => {
targetRef.current = value;
}, []);
const handlePasswordChange = useCallback((value: string) => {
passwordRef.current = value;
}, []);
const handleTogglePasswordVisibility = useCallback(() => {
setIsPasswordVisible((currentlyVisible) => !currentlyVisible);
}, []);
const iconButtonStyle = useCallback(
(
state: PressableStateCallbackType & { hovered?: boolean; focused?: boolean },
): StyleProp<ViewStyle> => [
isCompact ? styles.iconButtonMd : styles.iconButtonSm,
resolveControlInteractionStyles(
{
controlRest: styles.controlRest,
controlHover: styles.controlHover,
controlActive: styles.controlActive,
controlDisabled: styles.controlDisabled,
},
{
hovered: state.hovered,
pressed: state.pressed,
focused: state.focused,
disabled: isSaving,
},
),
],
[isCompact, isSaving],
);
const handleSubmit = useCallback(() => void handleSave(), [handleSave]);
return (
@@ -146,6 +241,41 @@ export function AddRemoteSshHostModal({
onSubmitEditing={handleSubmit}
/>
</Field>
<Field label={t("pairing.remoteSsh.fields.password")} testID="remote-ssh-password">
<View style={styles.passwordRow}>
<View style={styles.passwordInputWrap}>
<FormTextInput
ref={passwordInputRef}
size={isCompact ? "md" : "sm"}
testID="remote-ssh-password-input"
accessibilityLabel={t("pairing.remoteSsh.fields.password")}
initialValue=""
onChangeText={handlePasswordChange}
placeholder={t("pairing.remoteSsh.fields.optional")}
autoCapitalize="none"
autoCorrect={false}
secureTextEntry={!isPasswordVisible}
editable={!isSaving}
returnKeyType="done"
onSubmitEditing={handleSubmit}
/>
</View>
<Pressable
style={iconButtonStyle}
onPress={handleTogglePasswordVisibility}
disabled={isSaving}
accessibilityRole="button"
accessibilityLabel={
isPasswordVisible
? t("pairing.remoteSsh.passwordVisibility.hide")
: t("pairing.remoteSsh.passwordVisibility.show")
}
testID="remote-ssh-password-visibility-toggle"
>
{isPasswordVisible ? <ThemedEyeOff size={18} /> : <ThemedEye size={18} />}
</Pressable>
</View>
</Field>
<View style={styles.actions}>
<Button
style={FLEX_ONE_STYLE}
+5 -3
View File
@@ -108,6 +108,7 @@ import { AssistantForkMenu, type AssistantForkTarget } from "@/components/assist
import { useRetainedPanelActive } from "@/components/retained-panel";
import {
markdownCopyDataSet,
markdownCopyImageDataSet,
markdownCopyOrderedListDataSet,
markdownCopyTableCellDataSet,
type MarkdownCopyInlineTag,
@@ -884,10 +885,11 @@ function AssistantMarkdownImage({
],
[containerStyle, imageSizeStyle],
);
const copyDataSet = useMemo(() => markdownCopyImageDataSet(source, alt), [source, alt]);
if (image.status === "failed") {
return (
<View style={stateFrameStyle}>
<View style={stateFrameStyle} dataSet={copyDataSet}>
<Text style={assistantMessageStylesheet.imageErrorText}>{image.message}</Text>
</View>
);
@@ -895,14 +897,14 @@ function AssistantMarkdownImage({
if (!binding) {
return (
<View style={stateFrameStyle}>
<View style={stateFrameStyle} dataSet={copyDataSet}>
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
</View>
);
}
return (
<View style={frameStyle}>
<View style={frameStyle} dataSet={copyDataSet}>
<Pressable
accessibilityLabel={t("composer.attachments.openImage")}
accessibilityRole="button"
@@ -98,14 +98,21 @@ function WebHoverCard({
);
}, []);
// Keyboard focus keeps the card open for keyboard users; the focus a pointer click leaves
// behind on the trigger or a button in the card does not outlast the pointer leaving.
const keyboardFocusInside = useCallback(() => {
return focusInside() && document.activeElement?.matches(":focus-visible") === true;
}, [focusInside]);
const scheduleClose = useCallback(() => {
if (focusInside()) return;
if (keyboardFocusInside()) return;
if (graceTimerRef.current) return;
graceTimerRef.current = setTimeout(() => {
graceTimerRef.current = null;
if (keyboardFocusInside()) return;
setOpen(false);
}, CLOSE_GRACE_MS);
}, [focusInside]);
}, [keyboardFocusInside]);
const openNow = useCallback(() => {
clearGraceTimer();
@@ -55,6 +55,7 @@ import type {
import type { AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest";
import {
getFeatureHighlightColor,
isFeatureActive,
getFeatureTooltip,
getAgentControlHintKey,
resolveAgentModelSelection,
@@ -211,8 +212,7 @@ function getModeProviderDefinitions(modeControl: AgentModeControlValue | null) {
}
function getFeatureIconColor(
featureId: string,
enabled: boolean,
feature: AgentFeature,
palette: {
blue: { 400: string };
green: { 400: string };
@@ -220,11 +220,11 @@ function getFeatureIconColor(
},
foregroundMuted: string,
): string {
if (!enabled) {
if (!isFeatureActive(feature)) {
return foregroundMuted;
}
switch (getFeatureHighlightColor(featureId)) {
switch (getFeatureHighlightColor(feature.id)) {
case "blue":
return palette.blue[400];
case "green":
@@ -1335,8 +1335,7 @@ function DesktopFeatureItem({
<AgentControlTrigger
icon={FeatureIcon}
iconColor={getFeatureIconColor(
feature.id,
feature.value,
feature,
theme.colors.palette,
theme.colors.foregroundMuted,
)}
@@ -1370,6 +1369,11 @@ function DesktopFeatureItem({
<AgentControlTrigger
ref={featureAnchorRef}
icon={FeatureIcon}
iconColor={getFeatureIconColor(
feature,
theme.colors.palette,
theme.colors.foregroundMuted,
)}
surface="toolbar"
label={feature.label}
value={selectedOption?.label ?? feature.label}
@@ -1453,8 +1457,7 @@ function SheetFeatureItem({
ref={featureAnchorRef}
icon={FeatureIcon}
iconColor={getFeatureIconColor(
feature.id,
feature.value,
feature,
theme.colors.palette,
theme.colors.foregroundMuted,
)}
@@ -1490,6 +1493,11 @@ function SheetFeatureItem({
<AgentControlTrigger
ref={featureAnchorRef}
icon={FeatureIcon}
iconColor={getFeatureIconColor(
feature,
theme.colors.palette,
theme.colors.foregroundMuted,
)}
surface="sheet"
label={feature.label}
value={selectedOption?.label ?? feature.label}
@@ -3,6 +3,7 @@ import {
getFeatureHighlightColor,
getFeatureTooltip,
getAgentControlHintKey,
isFeatureActive,
normalizeModelId,
resolveAgentModelSelection,
} from "./utils";
@@ -35,9 +36,32 @@ describe("feature metadata helpers", () => {
it("maps feature highlight colors by feature id", () => {
expect(getFeatureHighlightColor("fast_mode")).toBe("yellow");
expect(getFeatureHighlightColor("service_tier")).toBe("yellow");
expect(getFeatureHighlightColor("plan_mode")).toBe("blue");
expect(getFeatureHighlightColor("other")).toBe("default");
});
it("treats a select feature as active when a non-default option is selected", () => {
const speed = {
type: "select" as const,
id: "service_tier",
label: "Speed",
options: [
{ id: "default", label: "Normal", isDefault: true },
{ id: "fast", label: "Fast" },
],
};
expect(isFeatureActive({ ...speed, value: "default" })).toBe(false);
expect(isFeatureActive({ ...speed, value: "fast" })).toBe(true);
expect(
isFeatureActive({
type: "toggle",
id: "fast_mode",
label: "Fast",
value: true,
}),
).toBe(true);
});
});
describe("normalizeModelId", () => {
@@ -1,7 +1,11 @@
import type { AgentFeature, AgentModelDefinition } from "@getpaseo/protocol/agent-types";
import { i18n } from "@/i18n/i18next";
import { formatThinkingOptionLabel } from "@/agent-controls/labels";
import { FAST_MODE_FEATURE_ID, PLAN_MODE_FEATURE_ID } from "@/agent-controls/policy";
import {
FAST_MODE_FEATURE_ID,
PLAN_MODE_FEATURE_ID,
SPEED_FEATURE_ID,
} from "@/agent-controls/policy";
export type ExplainedAgentControl = "mode" | "model" | "thinking";
export type FeatureHighlightColor = "blue" | "default" | "green" | "yellow";
@@ -35,9 +39,16 @@ export function getFeatureTooltip(feature: Pick<AgentFeature, "label" | "tooltip
return feature.tooltip ?? feature.label;
}
export function isFeatureActive(feature: AgentFeature): boolean {
if (feature.type === "toggle") return feature.value;
const selectedOption = feature.options.find((option) => option.id === feature.value);
return selectedOption !== undefined && !selectedOption.isDefault;
}
export function getFeatureHighlightColor(featureId: string): FeatureHighlightColor {
switch (featureId) {
case FAST_MODE_FEATURE_ID:
case SPEED_FEATURE_ID:
return "yellow";
case "auto_accept":
return "green";
@@ -5,7 +5,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import { HEADER_INNER_HEIGHT } from "@/constants/layout";
import { resolveContentMaxWidth, useAppSettings } from "@/hooks/use-settings";
import { KeyboardTranslateView } from "@/keyboard/shift";
import { createContext, useCallback, useContext, type ReactNode } from "react";
import { createContext, useCallback, useContext, useState, type ReactNode } from "react";
import { View, type LayoutChangeEvent, type ViewProps, StyleSheet } from "react-native";
import Animated, {
useAnimatedStyle,
@@ -15,7 +15,11 @@ import Animated, {
type SharedValue,
} from "react-native-reanimated";
import { useKeyboardShift } from "@/keyboard/shift";
import { updateComposerCapacity, type ComposerCapacity } from "./internal/capacity";
import {
resolveCenteredClearance,
updateComposerCapacity,
type ComposerCapacity,
} from "./internal/capacity";
const ViewportCapacity = createContext<SharedValue<number | undefined> | null>(null);
@@ -34,11 +38,12 @@ function ComposerViewport({
}: ComposerViewportProps) {
const measuredHeight = useSharedValue(0);
const sizing = useSharedValue<ComposerCapacity | undefined>(undefined);
const { layoutShift } = useKeyboardShift();
const { layoutShift, bottomInset: safeAreaBottom } = useKeyboardShift();
useAnimatedReaction(
() => ({
height: measuredHeight.value,
bottomInset,
safeAreaBottom: safeAreaBottom.value,
keyboardShift: layoutShift.value,
centered,
}),
@@ -79,6 +84,24 @@ function ComposerViewportContent({ style, ...props }: ViewProps) {
);
}
/** The keyboard only needs to move a centered form once it reaches the form's resting bottom. */
function useCenteredClearance(safeAreaBottom: number) {
const [viewportHeight, setViewportHeight] = useState(0);
const [formBottom, setFormBottom] = useState(0);
const measureViewport = useCallback((event: LayoutChangeEvent) => {
setViewportHeight(event.nativeEvent.layout.height);
}, []);
const measureForm = useCallback((event: LayoutChangeEvent) => {
const { y, height } = event.nativeEvent.layout;
setFormBottom(y + height);
}, []);
return {
value: resolveCenteredClearance({ viewportHeight, safeAreaBottom, formBottom }),
measureViewport,
measureForm,
};
}
interface ComposerDockProps {
children: [ReactNode, ReactNode, ReactNode?];
centered?: boolean;
@@ -91,6 +114,7 @@ export function ComposerDock({
}: ComposerDockProps) {
const insets = useSafeAreaInsets();
const contentMaxWidth = resolveContentMaxWidth(useAppSettings().settings);
const centeredClearance = useCenteredClearance(insets.bottom);
// Preserve the existing centered form's visual balance on tablets.
const bottomInset = centered ? HEADER_INNER_HEIGHT + 24 : 0;
if (centered) {
@@ -99,8 +123,13 @@ export function ComposerDock({
style={[dockStyles.centeredViewport, { paddingBottom: bottomInset }]}
bottomInset={bottomInset}
centered
onLayout={centeredClearance.measureViewport}
>
<KeyboardTranslateView style={[dockStyles.centered, { maxWidth: contentMaxWidth }]}>
<KeyboardTranslateView
style={[dockStyles.centered, { maxWidth: contentMaxWidth }]}
bottomClearance={centeredClearance.value}
onLayout={centeredClearance.measureForm}
>
<ComposerViewportContent style={dockStyles.composer}>
<ScrollView style={dockStyles.setup} keyboardShouldPersistTaps="handled">
{content}
@@ -1,9 +1,13 @@
import { describe, expect, it } from "vitest";
import { resolveComposerCapacity, updateComposerCapacity } from "./capacity";
import {
resolveCenteredClearance,
resolveComposerCapacity,
updateComposerCapacity,
} from "./capacity";
describe("composer viewport", () => {
it("preserves the editing capacity when the keyboard closes", () => {
const viewport = { height: 582, bottomInset: 24, centered: false };
const viewport = { height: 582, bottomInset: 24, safeAreaBottom: 0, centered: false };
const open = updateComposerCapacity(undefined, { ...viewport, keyboardShift: 308 });
const closed = updateComposerCapacity(open, { ...viewport, keyboardShift: 0 });
expect(open.capacity).toBe(245);
@@ -15,6 +19,7 @@ describe("composer viewport", () => {
const open = updateComposerCapacity(undefined, {
height: 582,
bottomInset: 24,
safeAreaBottom: 0,
centered: false,
keyboardShift: 308,
});
@@ -22,6 +27,7 @@ describe("composer viewport", () => {
updateComposerCapacity(open, {
height: 650,
bottomInset: 24,
safeAreaBottom: 0,
centered: false,
keyboardShift: 0,
}).capacity,
@@ -30,6 +36,7 @@ describe("composer viewport", () => {
updateComposerCapacity(open, {
height: 0,
bottomInset: 24,
safeAreaBottom: 0,
centered: false,
keyboardShift: 0,
}),
@@ -39,6 +46,7 @@ describe("composer viewport", () => {
const height = resolveComposerCapacity({
height: 582,
bottomInset: 24,
safeAreaBottom: 0,
keyboardShift: 308,
centered: false,
});
@@ -46,23 +54,80 @@ describe("composer viewport", () => {
expect(582 - 24 - 308 - height).toBe(5);
});
it("keeps a centered tablet form below the header after translation", () => {
it("keeps a centered tablet form below the header once the keyboard reaches it", () => {
const viewport = 1000;
const bottomInset = 80;
const keyboardShift = 300;
const height = resolveComposerCapacity({
height: 1000,
bottomInset: 80,
keyboardShift: 300,
height: viewport,
bottomInset,
safeAreaBottom: 0,
keyboardShift,
centered: true,
});
expect(height).toBe(310);
expect((1000 - 80 - height) / 2 - 300).toBe(5);
expect(height).toBe(695);
const restingTop = (viewport - bottomInset - height) / 2;
const bottomClearance = resolveCenteredClearance({
viewportHeight: viewport,
safeAreaBottom: 0,
formBottom: restingTop + height,
});
expect(restingTop - Math.max(0, keyboardShift - bottomClearance)).toBe(5);
});
it("leaves a centered form room to edit on a short foldable viewport", () => {
expect(
resolveComposerCapacity({
height: 725,
bottomInset: 72,
safeAreaBottom: 0,
keyboardShift: 313,
centered: true,
}),
).toBe(407);
});
it("rests a centered form on the keyboard above the bottom safe area", () => {
// The keyboard shift excludes the safe area that the keyboard also covers.
const geometry = {
height: 660,
bottomInset: 60,
safeAreaBottom: 32,
keyboardShift: 288,
centered: true,
};
const height = resolveComposerCapacity(geometry);
const restingTop = (geometry.height - geometry.bottomInset - height) / 2;
const formBottom = restingTop + height;
const clearance = resolveCenteredClearance({
viewportHeight: geometry.height,
safeAreaBottom: geometry.safeAreaBottom,
formBottom,
});
const translation = Math.max(0, geometry.keyboardShift - clearance);
const keyboardTop = geometry.height - geometry.safeAreaBottom - geometry.keyboardShift;
expect(formBottom - translation).toBe(keyboardTop);
expect(restingTop - translation).toBe(5);
});
it("uses the measured viewport before the first keyboard opening", () => {
expect(
resolveComposerCapacity({ height: 582, bottomInset: 24, keyboardShift: 0, centered: false }),
resolveComposerCapacity({
height: 582,
bottomInset: 24,
safeAreaBottom: 0,
keyboardShift: 0,
centered: false,
}),
).toBe(553);
expect(
resolveComposerCapacity({ height: 300, bottomInset: 0, keyboardShift: 200, centered: false }),
resolveComposerCapacity({
height: 300,
bottomInset: 0,
safeAreaBottom: 0,
keyboardShift: 200,
centered: false,
}),
).toBe(95);
});
});
@@ -1,6 +1,8 @@
interface ComposerGeometry {
height: number;
bottomInset: number;
/** The keyboard also covers this space below its shift. */
safeAreaBottom: number;
keyboardShift: number;
centered: boolean;
}
@@ -12,11 +14,25 @@ export interface ComposerCapacity {
export function resolveComposerCapacity(input: ComposerGeometry): number {
"worklet";
// A centered form grows upward by half its height. Reserve both halves so
// translating it still leaves five layout points below the header.
const clearance = input.keyboardShift + 5;
const reservedSpace = input.centered ? clearance * 2 : clearance;
return Math.max(0, input.height - input.bottomInset - reservedSpace);
// A centered form only translates by the keyboard overlap, so once the keyboard
// reaches it the form sits on the keyboard and fits above it, not twice over.
// A bottom-anchored composer pads the safe area inside its own capacity.
const available = input.centered
? Math.min(
input.height - input.bottomInset,
input.height - input.safeAreaBottom - input.keyboardShift,
)
: input.height - input.bottomInset - input.keyboardShift;
return Math.max(0, available - 5);
}
/** Space below a centered form that the keyboard fills before the form moves. */
export function resolveCenteredClearance(input: {
viewportHeight: number;
safeAreaBottom: number;
formBottom: number;
}): number {
return Math.max(0, input.viewportHeight - input.safeAreaBottom - input.formBottom);
}
export function updateComposerCapacity(
+6
View File
@@ -1703,6 +1703,12 @@ export const ar: TranslationResources = {
helper: "الاتصال بخادم Paseo يعمل على المضيف البعيد.",
fields: {
target: "مضيف SSH",
password: "كلمة مرور الدايمون",
optional: "خياري",
},
passwordVisibility: {
show: "إظهار كلمة المرور",
hide: "إخفاء كلمة المرور",
},
actions: {
cancel: "إلغاء",
+6
View File
@@ -1727,6 +1727,12 @@ export const en = {
helper: "Connect to a Paseo daemon running on the remote host.",
fields: {
target: "SSH host",
password: "Daemon password",
optional: "Optional",
},
passwordVisibility: {
show: "Show password",
hide: "Hide password",
},
actions: {
cancel: "Cancel",
+6
View File
@@ -1749,6 +1749,12 @@ export const es: TranslationResources = {
helper: "Conéctate a un daemon de Paseo en el host remoto.",
fields: {
target: "Host SSH",
password: "Contraseña del daemon",
optional: "Opcional",
},
passwordVisibility: {
show: "Mostrar contraseña",
hide: "Ocultar contraseña",
},
actions: {
cancel: "Cancelar",
+6
View File
@@ -1753,6 +1753,12 @@ export const fr: TranslationResources = {
helper: "Connectez-vous à un daemon Paseo sur l’hôte distant.",
fields: {
target: "Hôte SSH",
password: "Mot de passe du démon",
optional: "Facultatif",
},
passwordVisibility: {
show: "Afficher le mot de passe",
hide: "Masquer le mot de passe",
},
actions: {
cancel: "Annuler",
+6
View File
@@ -1719,6 +1719,12 @@ export const ja: TranslationResources = {
helper: "リモートホストで動作する Paseo デーモンに接続します。",
fields: {
target: "SSH ホスト",
password: "デーモンのパスワード",
optional: "任意",
},
passwordVisibility: {
show: "パスワードを表示",
hide: "パスワードを非表示",
},
actions: {
cancel: "キャンセル",
+6
View File
@@ -1711,6 +1711,12 @@ export const ko: TranslationResources = {
helper: "원격 호스트에서 실행 중인 Paseo 데몬에 연결합니다.",
fields: {
target: "SSH 호스트",
password: "데몬 비밀번호",
optional: "선택 사항",
},
passwordVisibility: {
show: "비밀번호 표시",
hide: "비밀번호 숨기기",
},
actions: {
cancel: "취소",
+6
View File
@@ -1734,6 +1734,12 @@ export const ptBR: TranslationResources = {
helper: "Conecte-se a um daemon Paseo no host remoto.",
fields: {
target: "Host SSH",
password: "Senha do daemon",
optional: "Opcional",
},
passwordVisibility: {
show: "Mostrar senha",
hide: "Ocultar senha",
},
actions: {
cancel: "Cancelar",
+6
View File
@@ -1732,6 +1732,12 @@ export const ru: TranslationResources = {
helper: "Подключитесь к демону Paseo на удалённом хосте.",
fields: {
target: "Хост SSH",
password: "Пароль демона",
optional: "Необязательно",
},
passwordVisibility: {
show: "Показать пароль",
hide: "Скрыть пароль",
},
actions: {
cancel: "Отмена",
+6
View File
@@ -1683,6 +1683,12 @@ export const zhCN: TranslationResources = {
helper: "连接到远程主机上运行的 Paseo 守护进程。",
fields: {
target: "SSH 主机",
password: "守护进程密码",
optional: "可选",
},
passwordVisibility: {
show: "显示密码",
hide: "隐藏密码",
},
actions: {
cancel: "取消",
@@ -6,17 +6,20 @@ import { useKeyboardShift } from "./context";
interface KeyboardTranslateViewProps extends ViewProps {
children: ReactNode;
enabled?: boolean;
/** Space below the view's resting position that the keyboard may fill before the view moves. */
bottomClearance?: number;
}
export function KeyboardTranslateView({
children,
enabled = true,
bottomClearance = 0,
style,
...props
}: KeyboardTranslateViewProps) {
const { shift } = useKeyboardShift();
const keyboardStyle = useAnimatedStyle(() => ({
transform: [{ translateY: enabled ? -shift.value : 0 }],
transform: [{ translateY: enabled ? -Math.max(0, shift.value - bottomClearance) : 0 }],
}));
return (
@@ -6,19 +6,31 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
interface KeyboardTranslateViewProps extends ViewProps {
children: ReactNode;
enabled?: boolean;
/** Space below the view's resting position that the keyboard may fill before the view moves. */
bottomClearance?: number;
}
export function KeyboardTranslateView({
children,
enabled = true,
bottomClearance = 0,
style,
...props
}: KeyboardTranslateViewProps) {
const insets = useSafeAreaInsets();
const { height, progress } = useKeyboardAnimation();
const translateY = useMemo(
() => Animated.add(height, Animated.multiply(progress, insets.bottom)),
[height, insets.bottom, progress],
() =>
Animated.add(
Animated.add(height, Animated.multiply(progress, insets.bottom)),
bottomClearance,
).interpolate({
inputRange: [-1, 0],
outputRange: [-1, 0],
extrapolateLeft: "extend",
extrapolateRight: "clamp",
}),
[bottomClearance, height, insets.bottom, progress],
);
const keyboardStyle = useMemo(
() => ({ transform: [{ translateY: enabled ? translateY : 0 }] }),
@@ -4,11 +4,13 @@ import { View, type ViewProps } from "react-native";
interface KeyboardTranslateViewProps extends ViewProps {
children: ReactNode;
enabled?: boolean;
bottomClearance?: number;
}
export function KeyboardTranslateView({
children,
enabled: _enabled,
bottomClearance: _bottomClearance,
...props
}: KeyboardTranslateViewProps) {
return <View {...props}>{children}</View>;
@@ -3919,6 +3919,53 @@ describe("HostRuntimeStore", () => {
store.syncHosts([]);
});
it("authenticates Remote SSH probes with the entered daemon password and stores it verbatim", async () => {
const store = createPairingStore({
password: " s3cret ",
serverIdForHost: (host) => (host.connections[0]?.type === "remoteSsh" ? "srv_ssh" : ""),
});
answerHostConfirmations(store, false);
await store.boot();
await store.probeAndUpsertRemoteSshConnection({
host: "deploy@example.com",
password: " s3cret ",
});
const host = store.getHosts().find((entry) => entry.serverId === "srv_ssh");
expect(host?.password).toBe(" s3cret ");
expect(host?.connections[0]).toMatchObject({
type: "remoteSsh",
host: "deploy@example.com",
});
// The fix promises the saved password is sent on every connection: a later
// cycle must authenticate from the stored profile alone.
await store.runProbeCycleNow("srv_ssh");
await waitForHostOnline(store, "srv_ssh");
store.syncHosts([]);
});
it("rejects Remote SSH probes without the daemon password, the way the daemon does", async () => {
const store = createPairingStore({
password: "s3cret",
serverIdForHost: (host) => (host.connections[0]?.type === "remoteSsh" ? "srv_ssh" : ""),
});
answerHostConfirmations(store, false);
await store.boot();
const failure = await store
.probeAndUpsertRemoteSshConnection({ host: "deploy@example.com" })
.then(
() => null,
(error: unknown) => error,
);
expect(failure).toBeInstanceOf(DaemonAuthenticationError);
expect((failure as DaemonAuthenticationError).reason).toBe("password_required");
expect(store.getHosts().find((entry) => entry.serverId === "srv_ssh")).toBeUndefined();
store.syncHosts([]);
});
it("preserves the existing host label when re-pairing an existing relay host", async () => {
const store = createPairingStore({ hostname: "mbp" });
answerHostConfirmations(store, true);
+5
View File
@@ -1916,10 +1916,14 @@ export class HostRuntimeStore {
host: string;
sshPort?: number;
daemonPort?: number;
password?: string;
label?: string;
}): Promise<{ profile: HostProfile; serverId: string; hostname: string | null }> {
return this.probeAndUpsertConnection({
label: input.label,
// The daemon hashes and compares the password verbatim (`paseo daemon
// set-password` keeps what it reads), so whitespace is significant here.
password: input.password ? input.password : undefined,
connection: createRemoteSshHostConnection(input),
});
}
@@ -2844,6 +2848,7 @@ export interface HostMutations {
host: string;
sshPort?: number;
daemonPort?: number;
password?: string;
label?: string;
}) => Promise<{ profile: HostProfile; serverId: string; hostname: string | null }>;
beginLinkPairing: () => LinkPairing;
+9 -1
View File
@@ -27,7 +27,12 @@ export function AgentUsage({
<>
<View style={styles.divider} />
{view.kind === "ready" ? (
<AgentUsageCards serverId={serverId} reports={view.reports} refreshable={refreshable} />
<AgentUsageCards
serverId={serverId}
agentId={agentId}
reports={view.reports}
refreshable={refreshable}
/>
) : (
<Text style={styles.message}>
{view.kind === "loading" ? usageCopy.loading : view.message}
@@ -39,10 +44,12 @@ export function AgentUsage({
function AgentUsageCards({
serverId,
agentId,
reports,
refreshable,
}: {
serverId: string;
agentId: string;
reports: readonly UsageReportEntry[];
refreshable: boolean;
}) {
@@ -51,6 +58,7 @@ function AgentUsageCards({
<UsageCard
key={entry.id}
serverId={serverId}
agentId={agentId}
entry={entry}
display={display}
compact
+20 -5
View File
@@ -40,11 +40,24 @@ function reportContent(report: UsageReport) {
return { windows: [], balances: [], details: [], message };
}
function reportMessages(entry: UsageReportEntry): string[] {
if (entry.report.status === "available") return [];
if (entry.loginErrors)
return entry.loginErrors.map(
(login) => `${login.harness}: ${reportContent(login.report).message}`,
);
// COMPAT(usageLoginErrors): added in v0.11.0, remove after 2027-04-05 once daemon floor >= v0.11.0.
return [
entry.report.status === "error" ? entry.report.error : usageCopy.problem(entry.report.problem),
];
}
const ThemedRotateCw = withUnistyles(RotateCw);
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
export function UsageCard({
serverId,
agentId,
entry,
display,
pinnable,
@@ -52,6 +65,7 @@ export function UsageCard({
compact = false,
}: {
serverId: string;
agentId?: string;
entry: UsageReportEntry;
display: UsageDisplay;
/** Whether each window row pins the window to the sidebar. */
@@ -61,13 +75,14 @@ export function UsageCard({
compact?: boolean;
}) {
const isCompact = useIsCompactFormFactor();
const { refresh, refreshState } = useReportRefresh(serverId, entry.id);
const { refresh, refreshState } = useReportRefresh(serverId, entry.id, agentId);
// Where there is no hover the freshness is printed on the card; elsewhere the Refresh tooltip.
const showsFreshnessInline = isNative || isCompact || !refreshable;
const usage = entry.report;
const status = statusText(usage);
const footer = entry.account.label ?? null;
const { windows, balances, details, message } = reportContent(usage);
const { windows, balances, details } = reportContent(usage);
const messages = reportMessages(entry);
const containerStyle = useMemo(
() => [styles.container, compact ? styles.containerCompact : styles.containerPadded],
@@ -110,9 +125,9 @@ export function UsageCard({
) : null}
</View>
{message ? (
<Text style={styles.error} numberOfLines={3}>
{message}
{messages.length > 0 ? (
<Text style={styles.error} testID="usage-login-error">
{messages.join("\n")}
</Text>
) : null}
+14 -8
View File
@@ -98,10 +98,14 @@ async function getReport(
serverId: string,
reportId: string,
forceRefresh = false,
agentId?: string,
): Promise<UsageReportEntry | null> {
return (
(await requireClient(serverId).listUsageReports({ reportIds: [reportId], forceRefresh }))
.reports[0] ?? null
(
await requireClient(serverId).listUsageReports(
agentId === undefined ? { reportIds: [reportId], forceRefresh } : { agentId, forceRefresh },
)
).reports.find((report) => report.id === reportId) ?? null
);
}
@@ -223,20 +227,22 @@ export function useUsageHosts(): UsageHost[] {
}
/**
* Forces the source to fetch one report, and only that report. The result replaces the report
* in every list of its host that holds it, the host's and each agent's, so every surface showing
* it moves together; until then the previous report stays on screen.
* Refreshes a host report, or the agent's session reports. The result replaces the card
* in the requesting view. Host and agent views can use different logins of the same account.
*/
export function useReportRefresh(
serverId: string,
reportId: string,
agentId?: string,
): { refresh: () => void; refreshState: UsageRefresh } {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: () => getReport(serverId, reportId, true),
mutationFn: () => getReport(serverId, reportId, true, agentId),
onSuccess: (report) => {
queryClient.setQueriesData<UsageReportEntry[]>(
{ queryKey: hostUsageQueryKey(serverId) },
queryClient.setQueryData<UsageReportEntry[]>(
agentId === undefined
? usageReportsQueryKey(serverId)
: agentUsageQueryKey(serverId, agentId),
(reports) => (reports ? replaceReport(reports, reportId, report) : reports),
);
},
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.11.0-beta.3",
"version": "0.11.0-beta.5",
"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.11.0-beta.3",
"@getpaseo/protocol": "0.11.0-beta.3",
"@getpaseo/server": "0.11.0-beta.3",
"@getpaseo/client": "0.11.0-beta.5",
"@getpaseo/protocol": "0.11.0-beta.5",
"@getpaseo/server": "0.11.0-beta.5",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
+5 -25
View File
@@ -6,6 +6,7 @@ export function addAttachOptions(cmd: Command): Command {
.argument("<id>", "Agent ID (or prefix)");
}
import { connectToDaemon } from "../../utils/client.js";
import { waitForStop } from "../../utils/wait-for-stop.js";
import {
fetchProjectedTimelineItems,
LIVE_HISTORY_FETCH_TIMEOUT_MS,
@@ -157,31 +158,10 @@ export async function runAttachCommand(
await unsubscribe.ready;
console.log(`Attached to agent ${resolvedId.substring(0, 7)}.`);
// Handle Ctrl+C to detach gracefully
let detached = false;
const detach = () => {
if (detached) return;
detached = true;
console.log("\n\nDetaching from agent...");
unsubscribe();
client
.close()
.then(() => {
process.exit(0);
})
.catch(() => {
process.exit(1);
});
};
process.on("SIGINT", detach);
process.on("SIGTERM", detach);
// Keep the process alive
await new Promise(() => {
// Wait indefinitely until interrupted
});
await waitForStop();
console.log("\n\nDetaching from agent...");
unsubscribe();
await client.close();
} catch (err) {
await client.close().catch(() => {});
const message = err instanceof Error ? err.message : String(err);
+3 -11
View File
@@ -1,5 +1,6 @@
import { Command } from "commander";
import { connectToDaemon } from "../../utils/client.js";
import { waitForStop } from "../../utils/wait-for-stop.js";
import type { CommandOptions } from "../../output/index.js";
import {
fetchProjectedTimelineItems,
@@ -224,16 +225,7 @@ async function runFollowMode(
await unsubscribe.ready;
console.log(`\n--- Following logs (${tailLabel}; Ctrl+C to stop) ---\n`);
// Wait for interrupt
await new Promise<void>((resolve) => {
const cleanup = () => {
unsubscribe();
resolve();
};
process.on("SIGINT", cleanup);
process.on("SIGTERM", cleanup);
});
await waitForStop();
unsubscribe();
await client.close();
}
+42
View File
@@ -1,10 +1,39 @@
import { spawn } from "node:child_process";
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import { createCli } from "./cli";
import { createCliParseArgv } from "./run";
const runModuleUrl = new URL("./run.ts", import.meta.url).href;
// Runs the CLI in a child process whose stdout pipe has no reader, as when the
// program that launched it has already closed its end.
async function runCliWithClosedStdout(
argv: string[],
): Promise<{ code: number | null; stderr: string }> {
const script = `
const { runCli } = await import(${JSON.stringify(runModuleUrl)});
process.exitCode = await runCli(${JSON.stringify(argv)});
await new Promise((resolve) => setTimeout(resolve, 100));
process.stderr.write("still running\\n");
`;
const child = spawn(process.execPath, ["--import", "tsx", "--input-type=module", "-e", script], {
cwd: path.dirname(fileURLToPath(import.meta.url)),
stdio: ["ignore", "pipe", "pipe"],
});
child.stdout.destroy();
let stderr = "";
child.stderr.setEncoding("utf8");
child.stderr.on("data", (chunk: string) => {
stderr += chunk;
});
const code = await new Promise<number | null>((resolve) => child.on("close", resolve));
return { code, stderr };
}
describe("runCli", () => {
it("lets plugin update own --version while preserving global output options", async () => {
const cli = createCli()
@@ -72,6 +101,19 @@ describe("runCli", () => {
).toEqual(["node", "paseo", "hooks", "claude", "UserPromptSubmit"]);
});
it("finishes the command quietly when stdout is closed before it writes its output", async () => {
const home = mkdtempSync(path.join(tmpdir(), "paseo-cli-closed-stdout-"));
try {
const result = await runCliWithClosedStdout(["daemon", "status", "--json", "--home", home]);
expect(result.stderr).toBe("still running\n");
expect(result.code).toBe(0);
} finally {
rmSync(home, { recursive: true, force: true });
}
}, 30_000);
it("classifies existing unknown directories as open-project invocations", () => {
const root = mkdtempSync(path.join(tmpdir(), "paseo-cli-run-"));
const project = path.join(root, "repository");
+14
View File
@@ -36,7 +36,21 @@ export function createCliParseArgv(input: {
return [...nodeArgv, ...cliArgv];
}
// The program that started the CLI can close its end of stdout before the output
// is written, as `paseo ls | head -1` or a launcher that discards output does.
// Drop the output nobody reads and let the command finish, instead of surfacing
// the failed write as an uncaught error.
function ignoreClosedStdout(error: NodeJS.ErrnoException): void {
if (error.code !== "EPIPE") {
throw error;
}
}
export async function runCli(argv: string[], options: RunCliOptions = {}): Promise<number> {
if (!process.stdout.listeners("error").includes(ignoreClosedStdout)) {
process.stdout.on("error", ignoreClosedStdout);
}
const parseArgv = createCliParseArgv({
argv,
cwd: options.cwd ?? process.cwd(),
+24
View File
@@ -0,0 +1,24 @@
// Commands that stream until stopped (`paseo agent logs --follow`, `paseo agent
// attach`) stop on Ctrl+C, a termination signal, or when the program reading
// their output closes its end, as `| head` does. A closed reader shows up as
// EPIPE on the next write to stdout, so the command stops at its next line of
// output.
export function waitForStop(): Promise<void> {
return new Promise((resolve) => {
const stop = () => {
process.off("SIGINT", stop);
process.off("SIGTERM", stop);
process.stdout.off("error", stopOnClosedStdout);
resolve();
};
const stopOnClosedStdout = (error: NodeJS.ErrnoException) => {
if (error.code === "EPIPE") {
stop();
}
};
process.on("SIGINT", stop);
process.on("SIGTERM", stop);
process.stdout.on("error", stopOnClosedStdout);
});
}
@@ -0,0 +1,49 @@
#!/usr/bin/env npx tsx
/**
* Follow commands stop when nobody reads their output
*
* `paseo agent logs --follow` and `paseo agent attach` stream until they are
* stopped. A program that closes its end of their stdout, as `| head` or a
* launcher that discards output does, stops them the same way Ctrl+C does.
*/
import assert from "node:assert";
import { connectToDaemon } from "../src/utils/client.ts";
import { runPaseoCli, startTestDaemon } from "./helpers/test-daemon.ts";
console.log("=== Follow Commands With Closed Stdout Tests ===\n");
const daemon = await startTestDaemon();
const host = `127.0.0.1:${daemon.port}`;
try {
// An agent created without a prompt stays idle, so no provider is called.
const client = await connectToDaemon({ target: { kind: "endpoint", host } });
const agent = await client.createAgent({
provider: "claude",
cwd: daemon.workDir,
title: "closed stdout",
});
await client.close();
for (const args of [
["agent", "logs", agent.id, "--follow"],
["agent", "attach", agent.id],
]) {
const label = `paseo ${args.slice(0, 2).join(" ")}`;
console.log(`Test: ${label} exits when its stdout reader is gone`);
const result = await runPaseoCli(daemon, [...args, "--host", host], {
closedStdout: true,
timeout: 20_000,
});
assert.strictEqual(result.stderr, "", `${label} should exit quietly`);
assert.strictEqual(result.exitCode, 0, `${label} should exit with code 0`);
console.log(`✓ ${label} exits when its stdout reader is gone\n`);
}
} finally {
await daemon.stop();
}
console.log("=== All follow commands with closed stdout tests passed ===");
@@ -352,6 +352,8 @@ export async function runPaseoCli(
timeout?: number;
cwd?: string;
env?: NodeJS.ProcessEnv;
/** Close the reading end of the CLI's stdout, as a launcher that discards output does. */
closedStdout?: boolean;
},
): Promise<{ exitCode: number; stdout: string; stderr: string }> {
const timeout = options?.timeout ?? 60000;
@@ -380,6 +382,10 @@ export async function runPaseoCli(
const stdout = createOutputCapture();
const stderr = createOutputCapture();
if (options?.closedStdout) {
proc.stdout?.destroy();
}
proc.stdout?.on("data", (data) => {
appendOutputCapture(stdout, data);
});
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/client",
"version": "0.11.0-beta.3",
"version": "0.11.0-beta.5",
"description": "Paseo client SDK package",
"files": [
"dist",
@@ -39,8 +39,8 @@
"test": "vitest run"
},
"dependencies": {
"@getpaseo/protocol": "0.11.0-beta.3",
"@getpaseo/relay": "0.11.0-beta.3",
"@getpaseo/protocol": "0.11.0-beta.5",
"@getpaseo/relay": "0.11.0-beta.5",
"zod": "^4.4.3"
},
"devDependencies": {
+15 -3
View File
@@ -2641,13 +2641,25 @@ async function runBrowserProfileGroup() {
async function main() {
ensureDirSync(OUT_DIR);
if (
!["all", "existing", "permanent-parking", "automation", "browser-profile"].includes(
HARNESS_GROUP,
)
![
"all",
"existing",
"permanent-parking",
"automation",
"browser-profile",
"titlebar-drag",
].includes(HARNESS_GROUP)
) {
fail(`unknown harness group ${HARNESS_GROUP}`);
}
if (HARNESS_GROUP === "titlebar-drag") {
const { runTitlebarDragGroup } = require("./titlebar-drag.js");
await runTitlebarDragGroup(OUT_DIR);
pass(`capture harness titlebar-drag complete output=${OUT_DIR}`);
return;
}
if (HARNESS_GROUP === "browser-profile") {
const browserProfileResults = await runBrowserProfileGroup();
await fsp.writeFile(
@@ -0,0 +1,52 @@
// titlebar-drag-mouse.m — native mouse helper for the titlebar-drag harness.
// Posts real CGEvents (kCGHIDEventTap) so a drag moves the actual window; pure
// CDP input would never exercise Electron's draggable-region pipeline.
// Usage: titlebar-drag-mouse drag x1 y1 x2 y2 | click x y
// Every event carries flags 0 so a user-held Cmd/Opt cannot leak into the drag.
// Exit 2 when this process lacks Accessibility trust: the harness fails loudly
// instead of posting nothing and reading as a pass.
#import <ApplicationServices/ApplicationServices.h>
#import <stdio.h>
#import <stdlib.h>
#import <string.h>
#import <unistd.h>
static void post(CGEventType type, CGPoint p, useconds_t delay) {
CGEventRef event = CGEventCreateMouseEvent(NULL, type, p, kCGMouseButtonLeft);
if (!event) {
fprintf(stderr, "mouse: CGEventCreateMouseEvent failed\n");
exit(3);
}
CGEventSetFlags(event, (CGEventFlags)0);
CGEventPost(kCGHIDEventTap, event);
CFRelease(event);
usleep(delay);
}
int main(int argc, char **argv) {
if (!AXIsProcessTrusted()) {
fprintf(stderr, "mouse: not accessibility-trusted\n");
return 2;
}
if (argc == 6 && strcmp(argv[1], "drag") == 0) {
CGPoint a = {atof(argv[2]), atof(argv[3])};
CGPoint b = {atof(argv[4]), atof(argv[5])};
post(kCGEventMouseMoved, a, 150000);
post(kCGEventLeftMouseDown, a, 150000);
for (int i = 1; i <= 20; i++) {
CGPoint step = {a.x + (b.x - a.x) * i / 20, a.y + (b.y - a.y) * i / 20};
post(kCGEventLeftMouseDragged, step, 25000);
}
post(kCGEventLeftMouseUp, b, 25000);
return 0;
}
if (argc == 4 && strcmp(argv[1], "click") == 0) {
CGPoint p = {atof(argv[2]), atof(argv[3])};
post(kCGEventMouseMoved, p, 150000);
post(kCGEventLeftMouseDown, p, 150000);
post(kCGEventLeftMouseUp, p, 25000);
return 0;
}
fprintf(stderr, "usage: %s drag x1 y1 x2 y2 | click x y\n", argv[0]);
return 1;
}
@@ -0,0 +1,272 @@
// Native mouse input is required: CDP mouse events do not move Electron windows.
// Load production CSS so restoring the global no-drag rule reproduces the failure.
const fs = require("node:fs");
const fsp = require("node:fs/promises");
const path = require("node:path");
const { execFile } = require("node:child_process");
const { promisify } = require("node:util");
const { BrowserWindow } = require("electron");
const execFileAsync = promisify(execFile);
const PACKAGE_ROOT = path.join(__dirname, "..", "..");
const INDEX_HTML = path.join(PACKAGE_ROOT, "app", "public", "index.html");
const MOUSE_SOURCE = path.join(__dirname, "titlebar-drag-mouse.m");
const BASE = { x: 100, y: 150 };
const DRAG_DELTA = { x: 80, y: 50 };
const DRAG_POINTS = [
{ check: "header-overlay-blank", x: 625, y: 18 },
{ check: "tab-row-overlay-blank", x: 625, y: 50 },
{ check: "sidebar-overlay-blank", x: 125, y: 18 },
{ check: "direct-rail-blank", x: 125, y: 50 },
];
const CLICK_POINTS = [
{ check: "header-button-click", id: "header-btn", x: 955, y: 18 },
{ check: "window-owned-toggle-click", id: "window-toggle", x: 40, y: 18 },
{ check: "window-owned-focusable-click", id: "focusable", x: 320, y: 18 },
{ check: "window-owned-negative-tabindex-button-click", id: "negative-button", x: 400, y: 18 },
{ check: "sidebar-tab-click", id: "sidebar-btn", x: 205, y: 18 },
{ check: "rail-tab-click", id: "rail-btn", x: 205, y: 50 },
{ check: "portal-button-click", id: "portal-btn", x: 490, y: 18 },
{ check: "floating-panel-button-click", id: "floating-btn", x: 785, y: 18 },
];
/** Static overlays and a sibling window control mirror the app shell ownership. */
function buildFixtureHtml(appStyles) {
const dragOverlay =
'style="position:absolute;top:0;left:0;display:block;width:100%;height:100%;-webkit-app-region:drag"';
const button = (id, style) =>
`<button id="${id}" style="${style}" onclick="count('${id}')">${id}</button>`;
return `<!doctype html><html><head><meta charset="utf-8"><style>${appStyles}</style>
<style>html,body{margin:0;width:100%;height:100%;background:#161616;color:#ddd;font:12px -apple-system,sans-serif;user-select:none}
.pane{position:absolute}</style></head><body>
<div class="pane" id="sidebar" style="left:0;top:0;width:250px;height:600px;background:#1d1d1d">
<div class="pane" style="left:0;top:0;width:250px;height:36px">
<div ${dragOverlay}></div>
${button("sidebar-btn", "position:absolute;right:8px;top:6px;width:60px;height:24px")}
</div>
<div class="pane"
style="left:0;top:36px;width:250px;height:28px;-webkit-app-region:drag">
${button("rail-btn", "position:absolute;right:8px;top:2px;width:60px;height:24px")}
</div>
</div>
<div class="pane" id="header" style="left:250px;top:0;width:750px;height:36px">
<div ${dragOverlay}></div>
${button("header-btn", "position:absolute;right:8px;top:6px;width:60px;height:24px")}
</div>
<div class="pane" style="left:250px;top:36px;width:750px;height:28px">
<div ${dragOverlay}></div>
</div>
<!-- The sidebar toggle belongs to the window, outside every drag surface subtree. -->
${button("window-toggle", "position:absolute;left:10px;top:6px;width:60px;height:24px;z-index:2")}
<div id="focusable" tabindex="0" onclick="count('focusable')"
style="position:absolute;left:290px;top:6px;width:60px;height:24px;z-index:2">Focusable</div>
<div id="negative-button" role="button" tabindex="-1" onclick="count('negative-button')"
style="position:absolute;left:370px;top:6px;width:60px;height:24px;z-index:2">Button</div>
<div class="pane" id="chat-root" style="left:250px;top:64px;width:750px;height:536px;overflow:clip">
<div id="scroller" tabindex="-1" data-window-content
style="width:100%;height:100%;overflow-y:auto;overflow-x:clip;outline:none">
<div id="chat-inner" style="height:2000px;position:relative">
<a id="chat-link" href="#" onclick="event.preventDefault();count('chat-link')" style="position:absolute;left:250px;top:160px;width:180px;height:24px">Chat link</a>
${button("chat-code", "position:absolute;left:250px;top:320px;width:180px;height:24px")}
<div id="chat-file" role="link" tabindex="0" onclick="count('chat-file')" style="position:absolute;left:250px;top:480px;width:180px;height:24px">file.ts</div>
</div>
</div>
</div>
<div id="overlay-root" style="position:fixed;inset:0;pointer-events:none;z-index:9999">
${button("portal-btn", "position:fixed;left:460px;top:6px;width:60px;height:24px;pointer-events:auto")}
</div>
<div style="position:fixed;inset:0;pointer-events:none;z-index:9999">
${button("floating-btn", "position:fixed;left:755px;top:6px;width:60px;height:24px;pointer-events:auto")}
</div>
<script>
window.__clicks = {};
window.count = (id) => { window.__clicks[id] = (window.__clicks[id] || 0) + 1; };
window.__fixture = {
setScroll(value) { document.getElementById("scroller").scrollTop = value; return this.snapshot(); },
placeControl(id, y) {
const control = document.getElementById(id);
const rect = control.getBoundingClientRect();
document.getElementById("scroller").scrollTop += rect.top + rect.height / 2 - y;
const placed = control.getBoundingClientRect();
return { x: placed.left + placed.width / 2, y: placed.top + placed.height / 2 };
},
snapshot() {
const inner = document.getElementById("chat-inner").getBoundingClientRect();
const clicks = { ...window.__clicks };
window.__clicks = {};
return { innerTop: inner.top, innerBottom: inner.bottom, clicks };
},
};
</script></body></html>`;
}
function readRealAppStyles() {
const html = fs.readFileSync(INDEX_HTML, "utf8");
const styles = [...html.matchAll(/<style[^>]*>([\s\S]*?)<\/style>/g)].map((m) => m[1]).join("\n");
if (!styles.trim()) throw new Error(`no <style> blocks found in ${INDEX_HTML}`);
return styles;
}
async function compileMouseHelper(outDir) {
const binary = path.join(outDir, "titlebar-drag-mouse");
await execFileAsync("clang", [
"-O2",
"-o",
binary,
MOUSE_SOURCE,
"-framework",
"ApplicationServices",
]);
return binary;
}
function samePosition(before, after) {
return before.x === after.x && before.y === after.y;
}
async function runTitlebarDragGroup(outDir) {
const results = [];
const record = (check, pass, detail) => {
results.push({ group: "titlebar-drag", check, pass, ...detail });
process.stdout.write(`${pass ? "PASS" : "FAIL"} titlebar-drag/${check}\n`);
};
let win = null;
let hardError = null;
try {
if (process.platform !== "darwin") {
throw new Error("titlebar-drag: macOS only — native window drag needs CGEventPost");
}
const mouse = await compileMouseHelper(outDir);
const fixturePath = path.join(outDir, "titlebar-drag-fixture.html");
await fsp.writeFile(fixturePath, buildFixtureHtml(readRealAppStyles()));
win = new BrowserWindow({
...BASE,
width: 1000,
height: 600,
frame: false,
show: false,
backgroundColor: "#161616",
});
const evalPage = (code) => win.webContents.executeJavaScript(code);
const bounds = () => {
const current = win.getBounds();
return { x: current.x, y: current.y };
};
const resetPosition = async () => {
win.setPosition(BASE.x, BASE.y);
await new Promise((resolve) => setTimeout(resolve, 120));
};
const runMouse = async (args) => {
await execFileAsync(mouse, args.map(String));
await new Promise((resolve) => setTimeout(resolve, 220));
};
const mouseAt = (point) => ({ x: BASE.x + point.x, y: BASE.y + point.y });
await win.loadFile(fixturePath);
win.showInactive();
await new Promise((resolve) => setTimeout(resolve, 300));
for (const scrollTop of [0, 1000]) {
const snapshot = await evalPage(`window.__fixture.setScroll(${scrollTop})`);
const crossesHeader = snapshot.innerTop < 36 && snapshot.innerBottom > 0;
record(`scroll-${scrollTop}-content-crosses-header`, crossesHeader === (scrollTop === 1000), {
innerTop: snapshot.innerTop,
innerBottom: snapshot.innerBottom,
});
for (const point of DRAG_POINTS) {
await resetPosition();
const before = bounds();
const from = mouseAt(point);
await runMouse(["drag", from.x, from.y, from.x + DRAG_DELTA.x, from.y + DRAG_DELTA.y]);
const after = bounds();
const moved = after.x - before.x === DRAG_DELTA.x && after.y - before.y === DRAG_DELTA.y;
record(`scroll-${scrollTop}-${point.check}`, moved, { before, after });
}
const chatPoint = { x: 625, y: 300 };
await resetPosition();
const before = bounds();
const from = mouseAt(chatPoint);
await runMouse(["drag", from.x, from.y, from.x + DRAG_DELTA.x, from.y + DRAG_DELTA.y]);
const after = bounds();
record(`scroll-${scrollTop}-chat-area-does-not-drag`, samePosition(before, after), {
before,
after,
});
}
for (const point of CLICK_POINTS) {
await resetPosition();
const before = bounds();
const from = mouseAt(point);
await runMouse(["click", from.x, from.y]);
const { clicks } = await evalPage("window.__fixture.snapshot()");
const after = bounds();
const clicked = clicks[point.id] === 1;
const stillStationary = samePosition(before, after);
record(point.check, clicked && stillStationary, { clicks, before, after });
await runMouse(["drag", from.x, from.y, from.x + DRAG_DELTA.x, from.y + DRAG_DELTA.y]);
const afterDrag = bounds();
record(`${point.check}-does-not-drag`, samePosition(before, afterDrag), {
before,
after: afterDrag,
});
}
for (const id of ["chat-link", "chat-code", "chat-file"]) {
for (const y of [18, 50]) {
await resetPosition();
const point = await evalPage(`window.__fixture.placeControl(${JSON.stringify(id)}, ${y})`);
const before = bounds();
const from = mouseAt(point);
await runMouse(["drag", from.x, from.y, from.x + DRAG_DELTA.x, from.y + DRAG_DELTA.y]);
const after = bounds();
record(
`${id}-behind-header-${y}`,
after.x - before.x === DRAG_DELTA.x && after.y - before.y === DRAG_DELTA.y,
{ before, after, point },
);
}
await resetPosition();
const point = await evalPage(`window.__fixture.placeControl(${JSON.stringify(id)}, 180)`);
const before = bounds();
const from = mouseAt(point);
await evalPage("window.__fixture.snapshot()");
await runMouse(["click", from.x, from.y]);
const { clicks } = await evalPage("window.__fixture.snapshot()");
const after = bounds();
record(`${id}-visible-click`, clicks[id] === 1 && samePosition(before, after), {
clicks,
before,
after,
point,
});
await runMouse(["drag", from.x, from.y, from.x + DRAG_DELTA.x, from.y + DRAG_DELTA.y]);
const afterDrag = bounds();
record(`${id}-visible-does-not-drag`, samePosition(before, afterDrag), {
before,
after: afterDrag,
});
}
const image = await win.webContents.capturePage();
await fsp.writeFile(path.join(outDir, "titlebar-drag.png"), image.toPNG());
} catch (error) {
hardError = error;
record("group-error", false, { reason: String(error?.message ?? error) });
} finally {
win?.destroy();
}
await fsp.writeFile(
path.join(outDir, "titlebar-drag-results.json"),
`${JSON.stringify({ generatedAt: new Date().toISOString(), results }, null, 2)}\n`,
);
const failed = results.filter((entry) => !entry.pass).map((entry) => entry.check);
if (hardError || failed.length > 0) {
throw new Error(`titlebar-drag group failed: ${failed.join(", ")}`, { cause: hardError });
}
return results;
}
module.exports = { runTitlebarDragGroup };
@@ -6,6 +6,8 @@ import { getE2EDaemonPort } from "../../app/e2e/support/helpers/daemon-port";
import { installDaemonWebSocketGate } from "../../app/e2e/support/helpers/daemon-websocket-gate";
import { expectAppRoute } from "../../app/e2e/support/helpers/route-assertions";
import { seedWorkspace } from "../../app/e2e/support/helpers/seed-client";
import { openAgentRoute, seedMockAgentWorkspace } from "../../app/e2e/support/helpers/mock-agent";
import { scrollAgentChatToBottom } from "../../app/e2e/support/helpers/agent-bottom-anchor";
import { getServerId } from "../../app/e2e/support/helpers/server-id";
import { waitForWorkspaceTabsVisible } from "../../app/e2e/support/helpers/workspace-tabs";
import {
@@ -118,3 +120,131 @@ test("refresh keeps one continuous splash before restoring the desktop workspace
await workspace.cleanup();
}
});
function inspectChatDragExclusions(scroll: Element) {
const header = document.querySelector('[data-testid="composer-dock-header"]');
if (!header) throw new Error("Expected the workspace header");
const headerRect = header.getBoundingClientRect();
const headerY = headerRect.top + headerRect.height / 2;
const content = scroll.firstElementChild;
const focusScope = scroll.closest("[tabindex]");
if (!content || !focusScope) throw new Error("Expected chat content and its focus scope");
const contentRect = content.getBoundingClientRect();
const chatX = (contentRect.left + contentRect.right) / 2;
const tabRow = [...document.querySelectorAll('[data-testid="workspace-tabs-row"]')].find(
(row) => {
const rect = row.getBoundingClientRect();
return rect.left < chatX && rect.right > chatX && rect.height > 0;
},
);
if (!tabRow) throw new Error("Expected the chat pane's tab row");
const tabRect = tabRow.getBoundingClientRect();
const tabY = tabRect.top + tabRect.height / 2;
const exclusions = [...scroll.querySelectorAll("*")].filter((element) => {
const rect = element.getBoundingClientRect();
const style = getComputedStyle(element);
return (
style.visibility === "visible" &&
style.getPropertyValue("-webkit-app-region") === "no-drag" &&
((rect.top < headerY && rect.bottom > headerY) || (rect.top < tabY && rect.bottom > tabY)) &&
rect.right > headerRect.left &&
rect.left < headerRect.right
);
});
return {
contentCrossesHeader: contentRect.top < headerY && contentRect.bottom > headerY,
contentCrossesTabRow: contentRect.top < tabY && contentRect.bottom > tabY,
focusScopeRegion: getComputedStyle(focusScope).getPropertyValue("-webkit-app-region"),
exclusions: exclusions.length,
};
}
test("scrolled chat does not exclude the workspace titlebar from dragging", async ({
page,
}, testInfo) => {
const response = Array.from(
{ length: 80 },
(_, index) =>
`Paragraph ${index}: a long conversation keeps the chat scrolled below its first message.\n\n[Drag reference](https://example.com) and [file.ts](./file.ts)\n\n\`\`\`ts\nconst item = ${index};\n\`\`\``,
).join("\n\n");
const agent = await seedMockAgentWorkspace({
repoPrefix: "desktop-scrolled-chat-",
title: "Scrollable conversation",
initialPrompt: "Show a long response",
featureValues: { mockAssistantResponse: response },
});
try {
await agent.client.waitForFinish(agent.agentId, 15_000);
await installDesktopRuntime(page, {
serverId: getServerId(),
daemonListen: `127.0.0.1:${getE2EDaemonPort()}`,
});
await openAgentRoute(page, agent);
const chat = page.getByTestId("agent-chat-scroll");
await expect(chat).toBeVisible();
for (const colorScheme of ["light", "dark"] as const) {
await page.emulateMedia({ colorScheme });
await scrollAgentChatToBottom(page);
// Electron can subtract no-drag rectangles outside a scroll viewport's clip.
// Check the offending geometry, rather than DOM hit testing or a specific row style.
await expect
.poll(() => chat.evaluate(inspectChatDragExclusions))
.toEqual({
contentCrossesHeader: true,
contentCrossesTabRow: true,
focusScopeRegion: "no-drag",
exclusions: 0,
});
}
// Window-owned controls are siblings of workspace chrome, not descendants of it.
const closeMenu = page.getByRole("button", { name: "Close menu", exact: true });
await expect(closeMenu).toHaveCSS("-webkit-app-region", "no-drag");
await closeMenu.click();
const openMenu = page.getByRole("button", { name: "Open menu", exact: true });
await expect(openMenu).toHaveCSS("-webkit-app-region", "no-drag");
await openMenu.click();
await expect(closeMenu).toBeVisible();
const menuTrigger = page.getByTestId("workspace-header-menu-trigger");
await expect(menuTrigger).toHaveCSS("-webkit-app-region", "no-drag");
await menuTrigger.click();
const menu = page.getByTestId("workspace-header-menu");
await expect(menu).toBeVisible();
await expect(menu.getByRole("menuitem").first()).toHaveCSS("-webkit-app-region", "no-drag");
await page.keyboard.press("Escape");
await expect(menu).toBeHidden();
await page.getByRole("textbox", { name: "Message agent...", exact: true }).click();
// installDesktopRuntime supplies a macOS bridge, including shortcut identity.
await page.keyboard.press("Meta+f");
const find = page.getByRole("textbox", { name: "Find in pane", exact: true });
await expect(find).toBeFocused();
await expect(find).toHaveCSS("-webkit-app-region", "no-drag");
await find.fill("Paragraph 40:");
await expect(page.getByRole("status", { name: "Find matches" })).toHaveText("1 of 1");
for (const name of ["Next match", "Previous match", "Close Find"]) {
await expect(page.getByRole("button", { name, exact: true })).toHaveCSS(
"-webkit-app-region",
"no-drag",
);
}
await page.getByRole("button", { name: "Close Find", exact: true }).click();
await expect(find).toBeHidden();
await expect
.poll(() =>
chat.evaluate((scroll) => ({
focusReturned: document.activeElement?.contains(scroll),
tabindex: document.activeElement?.getAttribute("tabindex"),
appRegion: getComputedStyle(document.activeElement!).getPropertyValue(
"-webkit-app-region",
),
})),
)
.toEqual({ focusReturned: true, tabindex: "-1", appRegion: "no-drag" });
await page.screenshot({ path: testInfo.outputPath("scrolled-chat-titlebar.png") });
} finally {
await agent.cleanup();
}
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.11.0-beta.3",
"version": "0.11.0-beta.5",
"private": true,
"description": "Paseo desktop app (Electron wrapper)",
"homepage": "https://paseo.sh",
@@ -1,5 +1,7 @@
import { describe, expect, it } from "vitest";
import { resolveCliInstallSourcePath } from "./path";
import { createRequire } from "node:module";
import path from "node:path";
import { resolveCliInstallSourcePath, resolveCliShimPath } from "./path";
describe("cli-install-path", () => {
it("uses the bundled shim for packaged macOS installs", () => {
@@ -56,3 +58,38 @@ describe("cli-install-path", () => {
).toBe("/opt/Paseo/resources/bin/paseo");
});
});
describe("CLI executable selection", () => {
const resolveWorkspaceCli = () =>
createRequire(import.meta.url).resolve("@getpaseo/cli/bin/paseo");
it("uses the workspace CLI for an unpackaged Electron launcher", () => {
expect(
resolveCliShimPath({
platform: "linux",
isPackaged: false,
executablePath: "/nix/store/electron/bin/electron",
resolveWorkspaceCli,
}),
).toBe(resolveWorkspaceCli());
});
it("uses the application shim for a packaged launcher", () => {
expect(
resolveCliShimPath({
platform: "linux",
isPackaged: true,
executablePath: "/opt/Paseo/paseo",
resolveWorkspaceCli,
}),
).toBe(path.join("/opt/Paseo", "resources", "bin", "paseo"));
expect(
resolveCliShimPath({
platform: "darwin",
isPackaged: true,
executablePath: "/Applications/Paseo.app/Contents/MacOS/Paseo",
resolveWorkspaceCli,
}),
).toBe(path.join("/Applications/Paseo.app", "Contents", "Resources", "bin", "paseo"));
});
});
@@ -1,3 +1,5 @@
import path from "node:path";
export function resolveCliInstallSourcePath(input: {
platform: NodeJS.Platform;
isPackaged: boolean;
@@ -27,3 +29,19 @@ export function resolveCliInstallSourcePath(input: {
return input.executablePath;
}
export function resolveCliShimPath(input: {
platform: NodeJS.Platform;
isPackaged: boolean;
executablePath: string;
resolveWorkspaceCli: () => string;
}): string {
if (!input.isPackaged) return input.resolveWorkspaceCli();
const filename = input.platform === "win32" ? "paseo.cmd" : "paseo";
if (input.platform === "darwin") {
const bundle = input.executablePath.replace(/\/Contents\/MacOS\/.+$/, "");
return path.join(bundle, "Contents", "Resources", "bin", filename);
}
return path.join(path.dirname(input.executablePath), "resources", "bin", filename);
}
@@ -1,4 +1,6 @@
import path from "node:path";
import { createRequire } from "node:module";
import { resolveCliShimPath } from "./path.js";
import os from "node:os";
import { app } from "electron";
@@ -12,20 +14,10 @@ export function getCliTargetPath(): string {
}
export function getBundledCliShimPath(): string {
const cliShimFilename = process.platform === "win32" ? "paseo.cmd" : "paseo";
if (process.platform === "darwin") {
const electronExePath = app.getPath("exe");
const appBundle = electronExePath.replace(/\/Contents\/MacOS\/.+$/, "");
return path.join(appBundle, "Contents", "Resources", "bin", cliShimFilename);
}
if (process.platform === "win32") {
const electronExePath = app.getPath("exe");
return path.join(path.dirname(electronExePath), "resources", "bin", cliShimFilename);
}
// Linux
const electronExePath = app.getPath("exe");
return path.join(path.dirname(electronExePath), "resources", "bin", cliShimFilename);
return resolveCliShimPath({
platform: process.platform,
isPackaged: app.isPackaged,
executablePath: app.getPath("exe"),
resolveWorkspaceCli: () => createRequire(__filename).resolve("@getpaseo/cli/bin/paseo"),
});
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/expo-two-way-audio",
"version": "0.11.0-beta.3",
"version": "0.11.0-beta.5",
"description": "Native module for two way audio streaming",
"keywords": [
"ExpoTwoWayAudio",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.11.0-beta.3",
"version": "0.11.0-beta.5",
"files": [
"dist",
"!dist/**/*.map",
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/plugin",
"version": "0.11.0-beta.3",
"version": "0.11.0-beta.5",
"description": "Paseo plugin SDK package",
"files": [
"dist",
@@ -70,8 +70,8 @@
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.11.0-beta.3",
"@getpaseo/protocol": "0.11.0-beta.3",
"@getpaseo/client": "0.11.0-beta.5",
"@getpaseo/protocol": "0.11.0-beta.5",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.1.0",
@@ -81,8 +81,8 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.11.0-beta.3",
"@getpaseo/protocol": "0.11.0-beta.3",
"@getpaseo/client": "0.11.0-beta.5",
"@getpaseo/protocol": "0.11.0-beta.5",
"react": "~19.1.0",
"react-native": ">=0.81.5",
"zod": "^4.4.3"
+5
View File
@@ -4,6 +4,7 @@ import { isBuiltin } from "node:module";
import { fileURLToPath } from "node:url";
import ts from "typescript";
import { describe, expect, it } from "vitest";
import * as server from "./server/index.js";
const sourceDirectory = path.dirname(fileURLToPath(import.meta.url));
const entries = {
@@ -64,6 +65,10 @@ function boundaryViolations(entry: string, runtime: "shared" | "server" | "clien
}
describe("plugin SDK import boundaries", () => {
it("publishes process capabilities without their Windows implementation helpers", () => {
expect(Object.keys(server).sort()).toEqual(["execCommand", "spawnProcess", "terminateProcess"]);
});
it("classifies every published entry", () => {
const manifest = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
expect(Object.keys(manifest.exports).sort()).toEqual(Object.keys(entries).sort());
@@ -1,4 +1,5 @@
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import type { ChildProcessWithoutNullStreams } from "node:child_process";
import { spawnProcess, terminateProcess } from "../process.js";
import { Readable, Writable } from "node:stream";
import {
ClientSideConnection,
@@ -400,9 +401,9 @@ class AcpRuntime {
args: options.options.command.slice(1),
env: process.env,
};
child = spawn(launch.command, launch.args, {
child = spawnProcess(launch.command, launch.args, {
env: { ...launch.env, ...options.env },
stdio: ["pipe", "pipe", "pipe"],
stdio: "pipe",
});
spawnFailure = new Promise<never>((_resolve, reject) => child!.once("error", reject));
child.stderr.on("data", () => undefined);
@@ -734,9 +735,9 @@ class AcpRuntime {
if (!child || this.processFailed) return;
if (child.exitCode !== null || child.signalCode !== null) return;
const closed = new Promise<void>((resolve) => child.once("close", () => resolve()));
child.kill("SIGTERM");
await terminateProcess(child, "SIGTERM");
if (await settlesWithin(closed, 1_000)) return;
child.kill("SIGKILL");
await terminateProcess(child);
if (!(await settlesWithin(closed, 1_000))) {
throw new Error(`ACP provider ${this.options.options.id} did not terminate after SIGKILL`);
}
+2
View File
@@ -22,3 +22,5 @@ export type {
PluginBeforeRequests,
PluginLifecycleRegistration,
} from "./lifecycle.js";
export { spawnProcess, execCommand, terminateProcess } from "./process.js";
@@ -0,0 +1,117 @@
import { afterEach, describe, expect, test } from "vitest";
import { quoteWindowsArgument, quoteWindowsCommand } from "./windows-command.js";
describe("quoteWindowsCommand", () => {
const originalPlatform = process.platform;
function setPlatform(value: string) {
Object.defineProperty(process, "platform", { value, writable: true });
}
afterEach(() => {
setPlatform(originalPlatform);
});
test("quotes a Windows path with spaces", () => {
setPlatform("win32");
expect(quoteWindowsCommand("C:\\Program Files\\Anthropic\\claude.exe")).toBe(
'"C:\\Program Files\\Anthropic\\claude.exe"',
);
});
test("does not double-quote an already-quoted path", () => {
setPlatform("win32");
expect(quoteWindowsCommand('"C:\\Program Files\\Anthropic\\claude.exe"')).toBe(
'"C:\\Program Files\\Anthropic\\claude.exe"',
);
});
test("returns the command unchanged when there are no spaces", () => {
setPlatform("win32");
expect(quoteWindowsCommand("C:\\nvm4w\\nodejs\\codex")).toBe("C:\\nvm4w\\nodejs\\codex");
});
test("escapes ampersands", () => {
setPlatform("win32");
expect(quoteWindowsCommand("feature&bugfix")).toBe("feature^&bugfix");
});
test("escapes pipes", () => {
setPlatform("win32");
expect(quoteWindowsCommand("feature|bugfix")).toBe("feature^|bugfix");
});
test("does not double percent signs", () => {
setPlatform("win32");
// cmd.exe only collapses %% → % inside batch files; on the command line
// it stays literal, which corrupts git --format atoms etc.
expect(quoteWindowsCommand("100%")).toBe("100%");
});
test("preserves git --format atoms verbatim", () => {
setPlatform("win32");
expect(quoteWindowsCommand("--format=%(refname)%09%(committerdate:unix)")).toBe(
"--format=%^(refname^)%09%^(committerdate:unix^)",
);
});
test("escapes carets", () => {
setPlatform("win32");
expect(quoteWindowsCommand("feature^bugfix")).toBe("feature^^bugfix");
});
test("escapes multiple metacharacters", () => {
setPlatform("win32");
expect(quoteWindowsCommand("build&(test|deploy)!<output>")).toBe(
"build^&^(test^|deploy^)^!^<output^>",
);
});
test("quotes commands with spaces after escaping metacharacters", () => {
setPlatform("win32");
expect(quoteWindowsCommand("C:\\Program Files\\My Tool&Stuff\\run 100%.cmd")).toBe(
'"C:\\Program Files\\My Tool^&Stuff\\run 100%.cmd"',
);
});
test("returns the command unchanged on non-Windows platforms", () => {
setPlatform("darwin");
expect(quoteWindowsCommand("/usr/local/bin/claude code")).toBe("/usr/local/bin/claude code");
});
});
describe("quoteWindowsArgument", () => {
const originalPlatform = process.platform;
function setPlatform(value: string) {
Object.defineProperty(process, "platform", { value, writable: true });
}
afterEach(() => {
setPlatform(originalPlatform);
});
test("quotes a Windows argument with spaces", () => {
setPlatform("win32");
expect(quoteWindowsArgument("C:\\Program Files\\Anthropic\\cli.js")).toBe(
'"C:\\Program Files\\Anthropic\\cli.js"',
);
});
test("does not double-quote an already-quoted argument", () => {
setPlatform("win32");
expect(quoteWindowsArgument('"C:\\Program Files\\Anthropic\\cli.js"')).toBe(
'"C:\\Program Files\\Anthropic\\cli.js"',
);
});
test("returns the argument unchanged when there are no spaces", () => {
setPlatform("win32");
expect(quoteWindowsArgument("--version")).toBe("--version");
});
test("returns the argument unchanged on non-Windows platforms", () => {
setPlatform("darwin");
expect(quoteWindowsArgument("/usr/local/bin/claude code")).toBe("/usr/local/bin/claude code");
});
});
@@ -0,0 +1,53 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, expect, test } from "vitest";
import { execCommand, spawnProcess } from "./index.js";
const roots: string[] = [];
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
async function writeArgumentReader(): Promise<string> {
const root = await mkdtemp(path.join(os.tmpdir(), "plugin process arguments "));
roots.push(root);
const script = path.join(root, "read arguments.mjs");
await writeFile(script, "process.stdout.write(JSON.stringify(process.argv.slice(2)));\n");
return script;
}
const argumentsToPreserve = [
"argument with spaces",
"--format=%(refname)%09%(committerdate:unix)",
"100%",
"feature&bugfix",
"feature|bugfix",
"feature^bugfix",
];
// A bare extensionless command selects the Windows shell; an absolute node.exe path would
// skip that path. The real child checks quoting through the public process capabilities.
test("execCommand delivers shell arguments to the real child unchanged", async () => {
const script = await writeArgumentReader();
const { stdout } = await execCommand("node", [script, ...argumentsToPreserve]);
expect(JSON.parse(stdout)).toEqual(argumentsToPreserve);
});
test("spawnProcess delivers shell arguments to the real child unchanged", async () => {
const script = await writeArgumentReader();
const child = spawnProcess("node", [script, ...argumentsToPreserve], { stdio: "pipe" });
let stdout = "";
child.stdout.setEncoding("utf8");
child.stdout.on("data", (chunk: string) => {
stdout += chunk;
});
await new Promise<void>((resolve, reject) => {
child.once("error", reject);
child.once("close", (code) => {
if (code === 0) resolve();
else reject(new Error(`Argument reader exited with ${code}`));
});
});
expect(JSON.parse(stdout)).toEqual(argumentsToPreserve);
});
+119
View File
@@ -0,0 +1,119 @@
import {
execFile,
spawn,
type ChildProcess,
type ChildProcessWithoutNullStreams,
type SpawnOptions,
} from "node:child_process";
import { extname } from "node:path";
import { promisify } from "node:util";
import {
isWindowsCommandScript,
quoteWindowsArgument,
quoteWindowsCommand,
} from "./process-internal/windows-command.js";
const execFileAsync = promisify(execFile);
export interface ExecCommandOptions {
env?: NodeJS.ProcessEnv;
cwd?: string;
encoding?: BufferEncoding;
killSignal?: NodeJS.Signals;
timeout?: number;
maxBuffer?: number;
shell?: boolean | string;
signal?: AbortSignal;
}
interface ExecCommandResult {
stdout: string;
stderr: string;
}
function hasPathSeparator(value: string): boolean {
return value.includes("/") || value.includes("\\");
}
function shouldUseWindowsShell(
command: string,
requestedShell?: boolean | string,
): boolean | string {
if (isWindowsCommandScript(command)) {
return true;
}
if (requestedShell !== undefined) {
return requestedShell;
}
return process.platform === "win32" && !hasPathSeparator(command) && !extname(command);
}
/** Launch a CLI with Windows command-script handling and argv quoting. The caller owns env. */
export function spawnProcess(
command: string,
args: string[],
options: SpawnOptions & { stdio: "pipe" },
): ChildProcessWithoutNullStreams;
export function spawnProcess(command: string, args: string[], options?: SpawnOptions): ChildProcess;
export function spawnProcess(
command: string,
args: string[],
options?: SpawnOptions,
): ChildProcess {
const spawnOptions = options ?? {};
const isWindows = process.platform === "win32";
const shell = shouldUseWindowsShell(command, spawnOptions.shell);
const shouldQuoteForShell = isWindows && shell !== false;
const resolvedCommand = shouldQuoteForShell ? quoteWindowsCommand(command) : command;
const resolvedArgs = shouldQuoteForShell ? args.map(quoteWindowsArgument) : args;
return spawn(resolvedCommand, resolvedArgs, {
...spawnOptions,
env: options?.env,
shell,
signal: options?.signal,
windowsHide: true,
});
}
export async function execCommand(
command: string,
args: string[],
options?: ExecCommandOptions,
): Promise<ExecCommandResult> {
const isWindows = process.platform === "win32";
const shell = shouldUseWindowsShell(command, options?.shell);
const shouldQuoteForShell = isWindows && shell !== false;
const resolvedCommand = shouldQuoteForShell ? quoteWindowsCommand(command) : command;
const resolvedArgs = shouldQuoteForShell ? args.map(quoteWindowsArgument) : args;
return execFileAsync(resolvedCommand, resolvedArgs, {
cwd: options?.cwd,
env: options?.env,
encoding: options?.encoding ?? "utf8",
signal: options?.signal,
killSignal: options?.killSignal,
timeout: options?.timeout,
maxBuffer: options?.maxBuffer,
shell,
windowsHide: true,
}) as Promise<ExecCommandResult>;
}
/** Force-stop a CLI, including descendants behind Windows command-script launchers. */
export async function terminateProcess(
child: ChildProcess,
signal: NodeJS.Signals = "SIGKILL",
): Promise<void> {
if (process.platform !== "win32") {
child.kill(signal);
return;
}
if (child.pid === undefined) return;
try {
await execCommand("taskkill.exe", ["/PID", String(child.pid), "/T", "/F"], {
shell: false,
timeout: 5000,
});
} catch (error) {
// The process can finish between its close check and taskkill.
if (!(error instanceof Error && "code" in error && error.code === 128)) throw error;
}
}
+2
View File
@@ -58,6 +58,8 @@ export interface UsageAccount {
/** Stable across token rotation; [A-Za-z0-9._-]{1,128}. Never a credential or raw email. */
key: string;
label?: string;
/** Harness owning this login, e.g. Codex, OpenCode, Pi or OMP. */
harness?: string;
/** Store locator, opaque to the daemon. */
input: JsonValue;
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/protocol",
"version": "0.11.0-beta.3",
"version": "0.11.0-beta.5",
"description": "Paseo shared protocol schemas and wire types",
"files": [
"dist",
+11
View File
@@ -6289,6 +6289,17 @@ export const UsageReportEntrySchema = z.object({
sourceLabel: z.string(),
icon: z.string().optional(),
report: UsageReportSchema,
loginErrors: z
.array(
z.object({
harness: z.string(),
report: z.discriminatedUnion("status", [
z.object({ status: z.literal("unavailable"), problem: UsageProblemSchema }),
z.object({ status: z.literal("error"), error: z.string() }),
]),
}),
)
.optional(),
});
export const UsageListReportsUpdateMessageSchema = z.object({
type: z.literal("usage.list_reports.update"),
@@ -389,3 +389,50 @@ test("blocked setup preserves the legacy failed shape and optional provenance",
legacySnapshot.parse(failed),
);
});
test("usage login errors are additive and older reports still parse", () => {
const entry = {
id: "codex:account",
account: {},
fetchedAt: "2026-10-05T00:00:00.000Z",
sourceId: "codex",
sourceLabel: "Codex",
report: { status: "error", error: "Usage API returned 500" },
};
const legacy = z.object({
type: z.literal("usage.list_reports.update"),
payload: z.object({
requestId: z.string(),
report: z.object({
id: z.string(),
account: z.object({ label: z.string().optional() }),
fetchedAt: z.string(),
sourceId: z.string(),
sourceLabel: z.string(),
icon: z.string().optional(),
report: z.discriminatedUnion("status", [
z.object({ status: z.literal("available"), windows: z.array(z.unknown()) }),
z.object({ status: z.literal("unavailable"), problem: z.unknown() }),
z.object({ status: z.literal("error"), error: z.string() }),
]),
}),
}),
});
const oldMessage = {
type: "usage.list_reports.update",
payload: { requestId: "usage", report: entry },
};
const newMessage = {
...oldMessage,
payload: {
...oldMessage.payload,
report: {
...entry,
loginErrors: [{ harness: "Codex", report: entry.report }],
},
},
};
expect(SessionOutboundMessageSchema.parse(oldMessage)).toEqual(oldMessage);
expect(SessionOutboundMessageSchema.parse(newMessage)).toEqual(newMessage);
expect(legacy.parse(newMessage)).toEqual(oldMessage);
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.11.0-beta.3",
"version": "0.11.0-beta.5",
"description": "Paseo relay for bridging daemon and client connections",
"files": [
"dist",
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.11.0-beta.3",
"version": "0.11.0-beta.5",
"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.11.0-beta.3",
"@getpaseo/highlight": "0.11.0-beta.3",
"@getpaseo/plugin": "0.11.0-beta.3",
"@getpaseo/protocol": "0.11.0-beta.3",
"@getpaseo/relay": "0.11.0-beta.3",
"@getpaseo/client": "0.11.0-beta.5",
"@getpaseo/highlight": "0.11.0-beta.5",
"@getpaseo/plugin": "0.11.0-beta.5",
"@getpaseo/protocol": "0.11.0-beta.5",
"@getpaseo/relay": "0.11.0-beta.5",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
@@ -27,6 +27,8 @@ async function runSupervisorFixture(options: {
fileSizeLimitBlocks?: number;
/** Occupy the log path with a directory so the supervisor cannot open daemon.log. */
blockLogPath?: boolean;
/** Close the reading end of the supervisor's stdout and stderr as soon as it starts. */
closeOutput?: boolean;
}): Promise<{
code: number | null;
signal: NodeJS.Signals | null;
@@ -86,6 +88,11 @@ async function runSupervisorFixture(options: {
stdio: ["ignore", "pipe", "pipe"],
});
if (options.closeOutput) {
child.stdout.destroy();
child.stderr.destroy();
}
let stdout = "";
let stderr = "";
child.stdout.setEncoding("utf8");
@@ -116,10 +123,19 @@ async function runSupervisorFixture(options: {
});
});
const log = await readFile(logPath, "utf8");
const log = await readLogIfWritten(logPath);
return { code, signal, elapsedMs: Date.now() - startedAt, log, stdout, stderr };
}
async function readLogIfWritten(logPath: string): Promise<string> {
try {
return await readFile(logPath, "utf8");
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return "";
throw error;
}
}
describe("supervisor durable logging", () => {
test("resolves rotation defaults", () => {
const paseoHome = path.join(path.sep, "tmp", "paseo-home");
@@ -189,6 +205,30 @@ describe("supervisor durable logging", () => {
expect(result.stderr).toContain('"worker-json-stderr"');
});
test("keeps supervising the worker when nobody reads its stdout or stderr", async () => {
const result = await runSupervisorFixture({
closeOutput: true,
workerSource: `
process.on("message", (message) => {
if (message?.type === "paseo:graceful-shutdown") process.exit(0);
});
process.stdout.write("first stdout line\\n");
process.stderr.write("first stderr line\\n");
setTimeout(() => {
process.stdout.write("later stdout line\\n");
process.send?.({ type: "paseo:shutdown", reason: "closed_output_probe" });
}, 500);
setInterval(() => {}, 1000);
`,
});
expect(result.code).toBe(0);
expect(result.signal).toBeNull();
expect(result.log).toContain("first stderr line\n");
expect(result.log).toContain("later stdout line\n");
expect(result.log).toContain('"reason":"closed_output_probe"');
});
test("preserves raw non-JSON stdout and stderr lines", async () => {
const result = await runSupervisorFixture({
workerSource: `
+11
View File
@@ -166,6 +166,15 @@ function createDurableLog(
};
}
// The program that started the daemon can close its end of stdout or stderr, as
// `paseo daemon run | head -1` does. Drop the output nobody reads and keep supervising
// the worker; daemon.log still records it.
function ignoreClosedOutput(error: NodeJS.ErrnoException): void {
if (error.code !== "EPIPE") {
throw error;
}
}
export function runSupervisor(options: SupervisorOptions): SupervisorController {
const restartOnCrash = options.restartOnCrash ?? false;
const workerArgs = options.workerArgs ?? process.argv.slice(2);
@@ -482,6 +491,8 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController
process.on("SIGINT", () => forwardSignal("SIGINT"));
process.on("SIGTERM", () => forwardSignal("SIGTERM"));
process.stdout.on("error", ignoreClosedOutput);
process.stderr.on("error", ignoreClosedOutput);
process.stdout.write(`[${options.name}] ${options.startupMessage}\n`);
writeLifecycleLog(options.startupMessage);
@@ -3,12 +3,7 @@ import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, test, vi } from "vitest";
import {
executableExists,
findExecutable,
quoteWindowsArgument,
quoteWindowsCommand,
} from "./executable-resolution.js";
import { executableExists, findExecutable } from "./executable-resolution.js";
import { windowsExecutableResolution } from "./windows.js";
import { isPlatform } from "../test-utils/platform.js";
@@ -217,118 +212,3 @@ describe("executableExists", () => {
expect(executableExists("/missing/codex", () => false)).toBeNull();
});
});
describe("quoteWindowsCommand", () => {
const originalPlatform = process.platform;
function setPlatform(value: string) {
Object.defineProperty(process, "platform", { value, writable: true });
}
afterEach(() => {
setPlatform(originalPlatform);
});
test("quotes a Windows path with spaces", () => {
setPlatform("win32");
expect(quoteWindowsCommand("C:\\Program Files\\Anthropic\\claude.exe")).toBe(
'"C:\\Program Files\\Anthropic\\claude.exe"',
);
});
test("does not double-quote an already-quoted path", () => {
setPlatform("win32");
expect(quoteWindowsCommand('"C:\\Program Files\\Anthropic\\claude.exe"')).toBe(
'"C:\\Program Files\\Anthropic\\claude.exe"',
);
});
test("returns the command unchanged when there are no spaces", () => {
setPlatform("win32");
expect(quoteWindowsCommand("C:\\nvm4w\\nodejs\\codex")).toBe("C:\\nvm4w\\nodejs\\codex");
});
test("escapes ampersands", () => {
setPlatform("win32");
expect(quoteWindowsCommand("feature&bugfix")).toBe("feature^&bugfix");
});
test("escapes pipes", () => {
setPlatform("win32");
expect(quoteWindowsCommand("feature|bugfix")).toBe("feature^|bugfix");
});
test("does not double percent signs", () => {
setPlatform("win32");
// cmd.exe only collapses %% → % inside batch files; on the command line
// it stays literal, which corrupts git --format atoms etc.
expect(quoteWindowsCommand("100%")).toBe("100%");
});
test("preserves git --format atoms verbatim", () => {
setPlatform("win32");
expect(quoteWindowsCommand("--format=%(refname)%09%(committerdate:unix)")).toBe(
"--format=%^(refname^)%09%^(committerdate:unix^)",
);
});
test("escapes carets", () => {
setPlatform("win32");
expect(quoteWindowsCommand("feature^bugfix")).toBe("feature^^bugfix");
});
test("escapes multiple metacharacters", () => {
setPlatform("win32");
expect(quoteWindowsCommand("build&(test|deploy)!<output>")).toBe(
"build^&^(test^|deploy^)^!^<output^>",
);
});
test("quotes commands with spaces after escaping metacharacters", () => {
setPlatform("win32");
expect(quoteWindowsCommand("C:\\Program Files\\My Tool&Stuff\\run 100%.cmd")).toBe(
'"C:\\Program Files\\My Tool^&Stuff\\run 100%.cmd"',
);
});
test("returns the command unchanged on non-Windows platforms", () => {
setPlatform("darwin");
expect(quoteWindowsCommand("/usr/local/bin/claude code")).toBe("/usr/local/bin/claude code");
});
});
describe("quoteWindowsArgument", () => {
const originalPlatform = process.platform;
function setPlatform(value: string) {
Object.defineProperty(process, "platform", { value, writable: true });
}
afterEach(() => {
setPlatform(originalPlatform);
});
test("quotes a Windows argument with spaces", () => {
setPlatform("win32");
expect(quoteWindowsArgument("C:\\Program Files\\Anthropic\\cli.js")).toBe(
'"C:\\Program Files\\Anthropic\\cli.js"',
);
});
test("does not double-quote an already-quoted argument", () => {
setPlatform("win32");
expect(quoteWindowsArgument('"C:\\Program Files\\Anthropic\\cli.js"')).toBe(
'"C:\\Program Files\\Anthropic\\cli.js"',
);
});
test("returns the argument unchanged when there are no spaces", () => {
setPlatform("win32");
expect(quoteWindowsArgument("--version")).toBe("--version");
});
test("returns the argument unchanged on non-Windows platforms", () => {
setPlatform("darwin");
expect(quoteWindowsArgument("/usr/local/bin/claude code")).toBe("/usr/local/bin/claude code");
});
});
@@ -2,11 +2,8 @@ import { createRequire } from "node:module";
import { existsSync } from "node:fs";
import type { ProcessEnvRecord } from "../server/paseo-env.js";
import { execCommand } from "../utils/spawn.js";
import { isWindowsCommandScript } from "../utils/windows-command.js";
import { windowsExecutableResolution } from "./windows.js";
export { quoteWindowsArgument, quoteWindowsCommand } from "../utils/windows-command.js";
type Which = (
command: string,
options: { all: true; path?: string; pathExt?: string },
@@ -82,7 +79,6 @@ export async function probeExecutable(
timeout: timeoutMs,
killSignal: "SIGKILL",
maxBuffer: 64 * 1024,
shell: isWindowsCommandScript(executablePath),
});
return true;
} catch (error) {
@@ -890,6 +890,131 @@ test("unavailable steer interrupts once and starts one replacement turn", async
}
});
test("steers a send that arrives while the previous turn is still starting", async () => {
const startHeld = deferred<void>();
class SlowStartSteeringSession extends SteeringTestSession {
override async startTurn(prompt: AgentPromptInput): Promise<{ turnId: string }> {
await startHeld.promise;
return await super.startTurn(prompt);
}
}
const session = new SlowStartSteeringSession({
provider: "codex",
cwd: process.cwd(),
});
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-steer-starting-"));
const manager = new AgentManager({
clients: {
codex: new (class extends TestAgentClient {
override async createSession(): Promise<AgentSession> {
return session;
}
})(),
},
logger,
});
let agentId: string | null = null;
try {
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: undefined,
});
agentId = agent.id;
const steerOptions = (clientMessageId: string) => ({
replaceRunning: true,
activeTurnBehavior: "steer" as const,
runOptions: { clientMessageId },
});
await startAgentRun(manager, agent.id, "FIRST", logger, steerOptions("first-client"));
const second = startAgentRun(
manager,
agent.id,
"SECOND",
logger,
steerOptions("second-client"),
);
startHeld.resolve();
await expect(second).resolves.toEqual({ disposition: "steered" });
expect(session.startPrompts).toEqual(["FIRST"]);
expect(session.interruptCount).toBe(0);
expect(
manager
.fetchTimeline(agent.id, { limit: 0 })
.rows.filter((row) => row.item.type === "user_message")
.map((row) => ({
text: row.item.type === "user_message" ? row.item.text : null,
turnId: row.turnId,
})),
).toEqual([
{ text: "FIRST", turnId: "active-turn-1" },
{ text: "SECOND", turnId: "active-turn-1" },
]);
} finally {
startHeld.resolve();
if (agentId) await manager.closeAgent(agentId).catch(() => undefined);
rmSync(workdir, { recursive: true, force: true });
}
});
test("starts its own turn when the previous turn fails to start under a steer send", async () => {
const startHeld = deferred<void>();
class FailingFirstStartSession extends SteeringTestSession {
override async startTurn(prompt: AgentPromptInput): Promise<{ turnId: string }> {
if (prompt === "FIRST") {
this.startPrompts.push(prompt);
await startHeld.promise;
throw new Error("first start failed");
}
return await super.startTurn(prompt);
}
}
const session = new FailingFirstStartSession({
provider: "codex",
cwd: process.cwd(),
});
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-steer-failed-start-"));
const manager = new AgentManager({
clients: {
codex: new (class extends TestAgentClient {
override async createSession(): Promise<AgentSession> {
return session;
}
})(),
},
logger,
});
let agentId: string | null = null;
try {
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: undefined,
});
agentId = agent.id;
const steerOptions = (clientMessageId: string) => ({
replaceRunning: true,
activeTurnBehavior: "steer" as const,
runOptions: { clientMessageId },
});
await startAgentRun(manager, agent.id, "FIRST", logger, steerOptions("first-client"));
const second = startAgentRun(
manager,
agent.id,
"SECOND",
logger,
steerOptions("second-client"),
);
startHeld.resolve();
await expect(second).resolves.toEqual({ disposition: "turn_started" });
await vi.waitFor(() => expect(session.startPrompts).toEqual(["FIRST", "SECOND"]));
expect(session.interruptCount).toBe(0);
} finally {
startHeld.resolve();
if (agentId) await manager.closeAgent(agentId).catch(() => undefined);
rmSync(workdir, { recursive: true, force: true });
}
});
test("orders an accepted steer before output emitted while acknowledgement is pending", async () => {
const entered = deferred<void>();
const release = deferred<void>();
@@ -2448,7 +2448,7 @@ export class AgentManager {
throw error;
}
if (isStaleProviderSessionError(error)) {
pendingRun.start = { status: "failed", error: error.message };
this.runs.settleForegroundStart(pendingRun, { status: "failed", error: error.message });
agent.pendingReplacement = false;
if (!agent.activeForegroundTurnId) agent.lifecycle = "idle";
this.runs.settleForegroundRun(agentId, pendingRun.token);
@@ -2456,14 +2456,18 @@ export class AgentManager {
}
agent.pendingReplacement = false;
const errorMsg = error instanceof Error ? error.message : "Failed to start turn";
pendingRun.start = { status: "failed", error: errorMsg };
await this.handleStreamEvent(agent, {
type: "turn_failed",
provider: agent.provider,
error: errorMsg,
});
this.finalizeForegroundTurn(agent);
this.runs.settleForegroundRun(agentId, pendingRun.token);
this.runs.settleForegroundStart(pendingRun, { status: "failed", error: errorMsg });
try {
await this.handleStreamEvent(agent, {
type: "turn_failed",
provider: agent.provider,
error: errorMsg,
});
this.finalizeForegroundTurn(agent);
} finally {
// A steer waiting on this start is released only once the run is cleared.
this.runs.settleForegroundRun(agentId, pendingRun.token);
}
throw error;
}
}
@@ -2524,7 +2528,7 @@ export class AgentManager {
agent.pendingReplacement = false;
}
const turnStartedAt = new Date();
pendingRun.start = { status: "started", turnId };
this.runs.settleForegroundStart(pendingRun, { status: "started", turnId });
agent.activeForegroundTurnId = turnId;
this.openActiveTurn(agent, turnId, turnStartedAt);
agent.lifecycle = "running";
@@ -2730,6 +2734,9 @@ export class AgentManager {
prompt: AgentPromptInput,
options?: AgentSteerOptions,
): Promise<ActiveTurnSteerDispatchResult> {
// A turn that is still starting has no id to steer yet. Replacing it would drop its
// prompt, so wait until it is accepted (or fails) and steer the turn it became.
await this.runs.waitForForegroundStart(agentId);
const agent = this.requireSessionAgent(agentId);
const expectedTurnId = agent.activeForegroundTurnId ?? agent.activeTurnId;
if (!expectedTurnId) {
@@ -0,0 +1,51 @@
import { describe, expect, test } from "vitest";
import { AgentRunState } from "./agent-run-state.js";
function track(promise: Promise<void>): { readonly resolved: boolean } {
const state = { resolved: false };
void (async () => {
await promise;
state.resolved = true;
})();
return state;
}
async function flushMicrotasks(): Promise<void> {
await new Promise<void>((resolve) => setImmediate(resolve));
}
describe("waitForForegroundStart", () => {
test("resolves once the starting turn has an id", async () => {
const runs = new AgentRunState();
const run = runs.createPendingRun("agent");
const wait = track(runs.waitForForegroundStart("agent"));
await flushMicrotasks();
expect(wait.resolved).toBe(false);
runs.settleForegroundStart(run, { status: "started", turnId: "turn-1" });
await flushMicrotasks();
expect(wait.resolved).toBe(true);
});
test("waits for a failed start's run to be cleared", async () => {
const runs = new AgentRunState();
const run = runs.createPendingRun("agent");
const wait = track(runs.waitForForegroundStart("agent"));
runs.settleForegroundStart(run, { status: "failed", error: "start failed" });
await flushMicrotasks();
expect(wait.resolved).toBe(false);
expect(runs.hasPendingRun("agent")).toBe(true);
runs.settleForegroundRun("agent", run.token);
await flushMicrotasks();
expect(wait.resolved).toBe(true);
});
test("resolves at once when no turn is starting", async () => {
const runs = new AgentRunState();
await expect(runs.waitForForegroundStart("agent")).resolves.toBeUndefined();
});
});
@@ -10,14 +10,18 @@ export interface ForegroundTurnWaiter {
resolveSettled: () => void;
}
type ForegroundRunStart =
| { status: "pending" }
| { status: "started"; turnId: string }
| { status: "failed"; error: string };
export interface PendingForegroundRun {
token: string;
kind: "foreground";
stagedEvents: AgentStreamEvent[];
start:
| { status: "pending" }
| { status: "started"; turnId: string }
| { status: "failed"; error: string };
start: ForegroundRunStart;
turnStartedPromise: Promise<void>;
resolveTurnStarted: () => void;
settled: boolean;
settledPromise: Promise<void>;
resolveSettled: () => void;
@@ -54,6 +58,28 @@ export class AgentRunState {
return run?.kind === "foreground" ? run : null;
}
settleForegroundStart(
run: PendingForegroundRun,
start: Exclude<ForegroundRunStart, { status: "pending" }>,
): void {
run.start = start;
if (start.status === "started") {
run.resolveTurnStarted();
}
}
/**
* Resolves once the agent's foreground run has a turn id or has ended. A failed start
* resolves only once its run is cleared, so callers never see a failed run still tracked.
*/
async waitForForegroundStart(agentId: string): Promise<void> {
const run = this.getPendingRun(agentId);
if (run?.start.status !== "pending") {
return;
}
await Promise.race([run.turnStartedPromise, run.settledPromise]);
}
hasPendingRun(agentId: string): boolean {
return this.getPendingRun(agentId) !== null;
}
@@ -277,10 +303,16 @@ export class ForegroundTurnStream {
}
function createPendingForegroundRun(): PendingForegroundRun {
let resolveTurnStarted!: () => void;
const turnStartedPromise = new Promise<void>((resolvePromise) => {
resolveTurnStarted = resolvePromise;
});
return {
...createTrackedRunState(),
kind: "foreground",
start: { status: "pending" },
turnStartedPromise,
resolveTurnStarted,
stagedEvents: [],
};
}
@@ -634,6 +634,72 @@ describe("ClaudeAgentSession persisted subagent replay", () => {
expect(events[0]).toMatchObject({ id: TOOL_USE_ID });
});
test.each(["Agent", "Task"] as const)(
"labels a background %s call's card with its type and task after a restart",
async (toolName) => {
writeSession({
parentLines: [
parentEntry([
{
type: "tool_use",
id: TOOL_USE_ID,
name: toolName,
input: {
subagent_type: "general-purpose",
description: "Count files here",
prompt: "Run ls and reply with the number of entries.",
},
},
]),
JSON.stringify({
type: "user",
sessionId: "replay-session",
timestamp: "2026-07-26T06:27:48.000Z",
message: {
role: "user",
content: [
{
type: "tool_result",
tool_use_id: TOOL_USE_ID,
content: [
{
type: "text",
text: `Async agent launched successfully.\nagentId: ${AGENT_ID}`,
},
],
},
],
},
toolUseResult: { isAsync: true, status: "async_launched", agentId: AGENT_ID },
}),
],
meta: JSON.stringify({
agentType: "general-purpose",
description: "Count files here",
toolUseId: TOOL_USE_ID,
spawnDepth: 1,
requestShape: "background",
}),
});
const cards = (await replayEvents()).flatMap((event) =>
event.type === "timeline" &&
event.item.type === "tool_call" &&
event.item.callId === TOOL_USE_ID
? [event.item]
: [],
);
expect(cards.at(-1)).toMatchObject({
status: "completed",
detail: {
type: "sub_agent",
subAgentType: "general-purpose",
description: "Count files here",
},
});
},
);
test("replays the subagent's own transcript onto its timeline", async () => {
writeSession({
parentLines: [taskToolUse(), taskToolResult()],
@@ -130,6 +130,7 @@ import {
type ProviderCatalog,
type ProviderRefreshContext,
type ResolveAgentDefaultModeInput,
type ToolCallDetail,
} from "../../agent-sdk-types.js";
import { importSessionFromPersistence } from "../../provider-session-import.js";
import { runProviderRefreshActivity } from "../../provider-refresh-deadline.js";
@@ -295,6 +296,13 @@ interface AsyncMessageInput<T> {
interface ClaudeReplayOwnership {
restoredIds: ReadonlySet<string>;
toolOwners: ReadonlyMap<string, string>;
/** The parent's Task calls, keyed by tool-call id, so their cards replay labeled as they were live. */
subagentToolCalls: ReadonlyMap<string, ClaudeSubagentCardFacts>;
}
interface ClaudeSubagentCardFacts {
title?: string;
description?: string;
}
interface PersistedTimelineEntry {
@@ -4249,16 +4257,7 @@ class ClaudeAgentSession implements AgentSession {
return {
type: "timeline",
provider: "claude",
item: {
...toolCall,
detail: {
type: "sub_agent",
...(declaration.title ? { subAgentType: declaration.title } : {}),
...(declaration.description ? { description: declaration.description } : {}),
log: "",
actions: [],
},
},
item: { ...toolCall, detail: buildClaudeSubagentCardDetail(declaration) },
};
}
@@ -4977,6 +4976,7 @@ class ClaudeAgentSession implements AgentSession {
const sidechainEntries = [parentContent, ...sidechains.contents]
.flatMap(parseClaudeHistoryRecords)
.filter((entry) => entry.isSidechain === true && typeof entry.agentId === "string");
const parentFacts = readClaudeReplayParentFacts(parentEntries);
// Replay produces the same observations the live task protocol produces, then folds them
// with the same function, so identity and status are derived once for both paths.
@@ -4987,7 +4987,7 @@ class ClaudeAgentSession implements AgentSession {
entries,
parentFacts: readClaudeReplayParentFacts(entries as ClaudeHistoryEntry[]),
})),
parent: readClaudeReplayParentFacts(parentEntries),
parent: parentFacts,
convertEntry: (entry) => this.convertHistoryEntry(entry as ClaudeHistoryEntry),
});
const observations = [
@@ -5016,6 +5016,7 @@ class ClaudeAgentSession implements AgentSession {
const replay = {
restoredIds: restoredProviderSubagentIds,
toolOwners: subagentReplay.toolOwners,
subagentToolCalls: parentFacts.toolCalls,
};
if (observations.length === 0) return replay;
@@ -5082,7 +5083,12 @@ class ClaudeAgentSession implements AgentSession {
return;
}
const taskSnapshot = this.taskState.observe(entry);
const items = [...(taskSnapshot ? [taskSnapshot] : []), ...this.convertHistoryEntry(entry)];
const items = [
...(taskSnapshot ? [taskSnapshot] : []),
...this.convertHistoryEntry(entry).map((item) =>
labelReplayedSubagentCard(item, replay.subagentToolCalls),
),
];
const isVisibleUserEntry =
entry.type === "user" &&
typeof entry.uuid === "string" &&
@@ -5832,6 +5838,33 @@ function parseClaudeHistoryRecords(content: string): ClaudeHistoryEntry[] {
return entries;
}
/** The parent's card for a subagent, labeled with its type and task. */
function buildClaudeSubagentCardDetail(
facts: ClaudeSubagentCardFacts,
): Extract<ToolCallDetail, { type: "sub_agent" }> {
return {
type: "sub_agent",
...(facts.title ? { subAgentType: facts.title } : {}),
...(facts.description ? { description: facts.description } : {}),
log: "",
actions: [],
};
}
/**
* Live, a Task call's card is built from the task protocol's declaration. Replay has no task
* protocol, so the generic tool mapper leaves the card unlabeled; label it from the same facts.
*/
function labelReplayedSubagentCard(
item: AgentTimelineItem,
subagentToolCalls: ReadonlyMap<string, ClaudeSubagentCardFacts>,
): AgentTimelineItem {
if (item.type !== "tool_call") return item;
const facts = subagentToolCalls.get(item.callId);
if (!facts) return item;
return { ...item, detail: buildClaudeSubagentCardDetail(facts) };
}
/**
* Collect what the parent transcript knows about its Task calls, in the shape the shared replay
* source consumes. The agentId scrape is kept only as a fallback for sessions recorded before
@@ -154,6 +154,28 @@ describe("JsonlRpcProcess", () => {
}
});
test("publishes unmatched responses without publishing the response that settles a request", async () => {
const child = createInMemoryChildProcess();
const transport = startProcess({ child });
const messages: Record<string, unknown>[] = [];
transport.onMessage((message) => messages.push(message));
try {
const { id, promise } = transport.startRequest({ type: "prompt" });
const response = { id, type: "response", command: "prompt" };
const rejection = { ...response, success: false, error: "rejected after acknowledgement" };
const unknownResponse = { ...response, id: "unknown-request", success: true };
child.stdout.write(`${JSON.stringify({ ...response, success: true })}\n`);
child.stdout.write(`${JSON.stringify(rejection)}\n`);
child.stdout.write(`${JSON.stringify(unknownResponse)}\n`);
await expect(promise).resolves.toBeUndefined();
expect(messages).toEqual([rejection, unknownResponse]);
} finally {
await transport.close();
}
});
test("rejects unsuccessful responses", async () => {
const transport = startProcess();
@@ -258,25 +258,27 @@ export class JsonlRpcProcess {
this.frameDecoder.write(chunk);
}
/** Route a complete logical frame to its consumer (response or subscriber). */
/** Route a complete logical frame to its consumer (pending request or subscriber). */
private dispatchFrame(message: Record<string, unknown>): void {
if (message.type === "response") {
this.handleResponse(message as unknown as JsonlRpcResponse);
const pending =
message.type === "response" && typeof message.id === "string"
? this.pending.get(message.id)
: undefined;
if (pending) {
this.settleRequest(pending, message as unknown as JsonlRpcResponse & { id: string });
return;
}
// A response nothing is waiting for is published like any other frame, so a provider
// can read it as an event, such as OMP 18.3 rejecting a prompt after acknowledging it.
for (const subscriber of this.messageSubscribers) {
subscriber(message);
}
}
private handleResponse(response: JsonlRpcResponse): void {
if (!response.id) {
return;
}
const pending = this.pending.get(response.id);
if (!pending) {
return;
}
private settleRequest(
pending: PendingRequest,
response: JsonlRpcResponse & { id: string },
): void {
if (pending.timer) {
clearTimeout(pending.timer);
}
@@ -927,6 +927,24 @@ describe("OMP agent client and session", () => {
expect(omp.completedTurnCount()).toBe(1);
});
test.each(["result after ack", "result before ack"] as const)(
"fails a prompt OMP rejects before its agent runs (%s)",
async (order) => {
const omp = new OmpHarness();
await omp.start();
await expect(
omp.runPromptRejectedBeforeAgentRuns(
"Reply with ok.",
"No API key found for anthropic.",
order,
),
).rejects.toThrow("No API key found for anthropic.");
expect(omp.turnFailures()).toEqual(["No API key found for anthropic."]);
expect(omp.completedTurnCount()).toBe(0);
},
);
test("completes a no-turn notify with one notification and no assistant text", async () => {
const scheduler = new ManualNoTurnScheduler();
const omp = new OmpHarness({ noTurnScheduler: scheduler });
@@ -1218,6 +1236,25 @@ describe("OMP agent client and session", () => {
expect(omp.runtime().prompts).toEqual([{ message: "continue", imageCount: 0 }]);
});
test("ignores a prompt rejection a previous OMP process left for a reused request id", async () => {
const omp = new OmpHarness();
await omp.start();
omp.emit({
type: "prompt_result",
id: "req_1",
agentInvoked: false,
status: "error",
error: { message: "No API key found for anthropic." },
});
omp.processExit("OMP RPC process exited with code 1 and signal null");
await omp.runPromptWithoutTurnOnNextRuntime("/session", "req_1");
expect(omp.runtimeLaunches()).toHaveLength(2);
expect(omp.turnFailures()).toEqual(["OMP RPC process exited with code 1 and signal null"]);
expect(omp.completedTurnCount()).toBe(1);
});
test("reports an immediate relaunch failure without retrying in a loop", async () => {
const omp = new OmpHarness();
await omp.start();
@@ -172,6 +172,21 @@ interface OmpPersistenceMetadata {
bridgedTools: Map<string, OmpBridgedToolIdentity>;
}
/** OMP's outcome for one prompt, correlated by the prompt's request id. */
interface OmpPromptResult {
agentInvoked: boolean;
/** Why OMP rejected the prompt before its agent ran, such as a missing API key. */
error: string | null;
}
function promptResultError(result: {
status?: string;
error?: { message: string };
}): string | null {
if (result.status !== "error") return null;
return result.error?.message || "OMP rejected the prompt";
}
interface StartTurnResult {
turnId: string;
}
@@ -695,7 +710,8 @@ export class OmpAgentSession implements AgentSession {
private readonly pendingNoTurnOutputs: Array<{ turnId: string; message: string }> = [];
private activePromptRequestId: string | null = null;
private activePromptAgentInvoked: boolean | null = null;
private readonly pendingPromptResults = new Map<string, boolean>();
private activePromptError: string | null = null;
private readonly pendingPromptResults = new Map<string, OmpPromptResult>();
private pendingNoTurnCompletionAbort: AbortController | null = null;
private lastKnownThinkingOptionId: string | null;
private outOfBandCompactionEmit: ((event: AgentStreamEvent) => void) | null = null;
@@ -865,12 +881,13 @@ export class OmpAgentSession implements AgentSession {
if (ack.requestId) {
this.pendingPromptResults.delete(ack.requestId);
}
this.activePromptAgentInvoked = correlatedResult ?? ack.agentInvoked ?? null;
if (correlatedResult === false) {
this.activePromptAgentInvoked = correlatedResult?.agentInvoked ?? ack.agentInvoked ?? null;
this.activePromptError = correlatedResult?.error ?? null;
if (correlatedResult?.agentInvoked === false) {
this.scheduleNoTurnPromptCompletion(turnId);
return;
}
if (correlatedResult !== true && ack.agentInvoked === false) {
if (correlatedResult?.agentInvoked !== true && ack.agentInvoked === false) {
await this.completeNoTurnPrompt(turnId);
return;
}
@@ -1028,6 +1045,9 @@ export class OmpAgentSession implements AgentSession {
const state = await next.getState();
if (this.closed) throw new Error("OMP session is closed");
this.unsubscribeRuntime?.();
// Request ids restart with each OMP process, so results held for the old one cannot
// correlate with prompts sent to the new one.
this.pendingPromptResults.clear();
this.runtimeSession = next;
this.hostTools = restarted.hostTools;
this.state = state;
@@ -1309,7 +1329,14 @@ export class OmpAgentSession implements AgentSession {
) {
return;
}
const error = this.activePromptError;
this.emitBufferedNoTurnOutputs(turnId);
if (error) {
this.usagePoller.stopTurn();
this.resetActiveTurn({ terminalizeWork: true });
this.emit({ type: "turn_failed", provider: this.provider, turnId, error });
return;
}
this.completeTurn(turnId, []);
}
@@ -1318,6 +1345,7 @@ export class OmpAgentSession implements AgentSession {
this.activeNoTurnPromptText = null;
this.activePromptRequestId = null;
this.activePromptAgentInvoked = null;
this.activePromptError = null;
this.pendingNoTurnOutputs.splice(0, this.pendingNoTurnOutputs.length);
}
@@ -1715,15 +1743,17 @@ export class OmpAgentSession implements AgentSession {
? event.agentInvoked
: undefined;
if (requestId && agentInvoked !== undefined) {
const result = { agentInvoked, error: promptResultError(event) };
if (requestId === this.activePromptRequestId && this.activeTurnId) {
this.activePromptAgentInvoked = agentInvoked;
this.activePromptError = result.error;
if (agentInvoked === false) {
this.scheduleNoTurnPromptCompletion(this.activeTurnId);
} else {
this.cancelNoTurnPromptCompletion();
}
} else if (this.activePromptRequestId === null) {
this.pendingPromptResults.set(requestId, agentInvoked);
this.pendingPromptResults.set(requestId, result);
}
}
return;

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