Compare commits

..
Author SHA1 Message Date
paseo-bot[bot] ebae928a9c test(codex): reproduce agent prompts restarting auto-compaction (#5728)
A prompt sent with send_agent_prompt's default replaces the running turn.
While Codex auto-compacts, each one aborts the compaction and the next
turn starts another, and the replaced prompt never reaches Codex. The
steer variant compacts once and delivers every message.

The send_agent_prompt case fails on main; the steer case passes.
2026-10-06 03:41:51 +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
142 changed files with 4244 additions and 632 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": {
+17 -4
View File
@@ -1,6 +1,6 @@
# Changelog
## 0.11.0-beta.4 - 2026-10-05
## 0.11.0-beta.5 - 2026-10-06
### Added
@@ -11,7 +11,7 @@
- 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 ([#5876](https://github.com/getpaseo/paseo/pull/5876))
- 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))
@@ -20,6 +20,7 @@
- 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))
@@ -29,7 +30,7 @@
- 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 ([#5708](https://github.com/getpaseo/paseo/pull/5708))
- 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))
@@ -43,11 +44,12 @@
### 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 ([#6067](https://github.com/getpaseo/paseo/pull/6067))
- 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))
@@ -60,12 +62,15 @@
- 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))
@@ -80,8 +85,11 @@
- 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 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))
@@ -89,11 +97,16 @@
- 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 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 old width after the pane resizes ([#5716](https://github.com/getpaseo/paseo/pull/5716))
+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.
+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/
+28 -28
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.11.0-beta.4",
"version": "0.11.0-beta.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.11.0-beta.4",
"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.4",
"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.4",
"version": "0.11.0-beta.5",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.11.0-beta.4",
"@getpaseo/protocol": "0.11.0-beta.4",
"@getpaseo/server": "0.11.0-beta.4",
"@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.4",
"version": "0.11.0-beta.5",
"dependencies": {
"@getpaseo/protocol": "0.11.0-beta.4",
"@getpaseo/relay": "0.11.0-beta.4",
"@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.4",
"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.4",
"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.4",
"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.4",
"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.4",
"@getpaseo/protocol": "0.11.0-beta.4",
"@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.4",
"@getpaseo/protocol": "0.11.0-beta.4",
"@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.4",
"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.4",
"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.4",
"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.4",
"@getpaseo/highlight": "0.11.0-beta.4",
"@getpaseo/plugin": "0.11.0-beta.4",
"@getpaseo/protocol": "0.11.0-beta.4",
"@getpaseo/relay": "0.11.0-beta.4",
"@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,7 +40146,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.11.0-beta.4",
"version": "0.11.0-beta.5",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
@@ -41753,7 +41753,7 @@
},
"plugins": {
"name": "@getpaseo/builtin-plugins",
"version": "0.11.0-beta.4",
"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.4",
"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.4",
"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.4",
"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.4",
"@getpaseo/protocol": "0.11.0-beta.4",
"@getpaseo/server": "0.11.0-beta.4",
"@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.4",
"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.4",
"@getpaseo/relay": "0.11.0-beta.4",
"@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.4",
"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.4",
"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.4",
"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.4",
"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.4",
"@getpaseo/protocol": "0.11.0-beta.4",
"@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.4",
"@getpaseo/protocol": "0.11.0-beta.4",
"@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.4",
"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.4",
"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.4",
"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.4",
"@getpaseo/highlight": "0.11.0-beta.4",
"@getpaseo/plugin": "0.11.0-beta.4",
"@getpaseo/protocol": "0.11.0-beta.4",
"@getpaseo/relay": "0.11.0-beta.4",
"@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) {
@@ -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
@@ -0,0 +1,248 @@
import { describe, expect, test, vi } from "vitest";
import { execFileSync } from "node:child_process";
import os from "node:os";
import path from "node:path";
import { createServer, type ServerResponse } from "node:http";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { once } from "node:events";
import { AgentManager } from "../agent-manager.js";
import { AgentStorage } from "../agent-storage.js";
import { sendPromptToAgent } from "../agent-prompt.js";
import { CodexAppServerAgentClient } from "./codex-app-server-agent.js";
import { createTestLogger } from "../../../test-utils/test-logger.js";
function isCodexInstalled(): boolean {
try {
return (
execFileSync("which", ["codex"], { encoding: "utf8" }).trim().length > 0
);
} catch {
return false;
}
}
function sse(events: Array<Record<string, unknown>>): string {
return events
.map(
(event) =>
`event: ${String(event.type)}\ndata: ${JSON.stringify(event)}\n\n`
)
.join("");
}
function assistantSse(id: string, text: string, totalTokens: number): string {
return sse([
{ type: "response.created", response: { id } },
{
type: "response.output_item.done",
item: {
type: "message",
role: "assistant",
id: `msg-${id}`,
content: [{ type: "output_text", text }],
},
},
{
type: "response.completed",
response: {
id,
usage: {
input_tokens: totalTokens,
input_tokens_details: null,
output_tokens: 0,
output_tokens_details: null,
total_tokens: totalTokens,
},
},
},
]);
}
function isCompactionRequest(body: string): boolean {
return body.includes("CONTEXT CHECKPOINT COMPACTION");
}
async function startServer() {
const requests: Array<{ compaction: boolean; body: string }> = [];
const heldCompactions: ServerResponse[] = [];
let index = 0;
const server = createServer((req, res) => {
const chunks: Buffer[] = [];
req.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
req.on("end", () => {
const body = Buffer.concat(chunks).toString("utf8");
if (req.method !== "POST" || req.url !== "/v1/responses") {
res.statusCode = 404;
res.end();
return;
}
const compaction = isCompactionRequest(body);
requests.push({ compaction, body });
res.statusCode = 200;
res.setHeader("content-type", "text/event-stream");
if (compaction) {
// Hold the summarization open, as a slow real compaction would be.
heldCompactions.push(res);
return;
}
index += 1;
// The first turn reports a context far above the auto-compact limit.
res.end(
assistantSse(
`resp-${index}`,
`reply ${index}`,
index === 1 ? 50_000 : 100
)
);
});
});
server.listen(0, "127.0.0.1");
await once(server, "listening");
const address = server.address();
if (!address || typeof address === "string") throw new Error("no address");
return {
url: `http://127.0.0.1:${address.port}`,
requests,
releaseCompactions() {
for (const res of heldCompactions.splice(0)) {
res.end(assistantSse("summary", "SUMMARY", 100));
}
},
close: () =>
new Promise<void>((resolve) => {
for (const res of heldCompactions.splice(0)) res.destroy();
server.closeAllConnections();
server.close(() => resolve());
}),
};
}
function writeConfig(codexHome: string, serverUrl: string): void {
writeFileSync(
path.join(codexHome, "config.toml"),
`
model = "mock-model"
approval_policy = "on-request"
sandbox_mode = "read-only"
model_provider = "mock_provider"
model_auto_compact_token_limit = 10000
[model_providers.mock_provider]
name = "Mock provider for test"
base_url = "${serverUrl}/v1"
wire_api = "responses"
request_max_retries = 0
stream_max_retries = 0
`
);
}
async function waitFor(
check: () => boolean,
label: string,
timeoutMs = 15_000
): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (!check()) {
if (Date.now() > deadline)
throw new Error(`Timed out waiting for ${label}`);
await new Promise((resolve) => setTimeout(resolve, 50));
}
}
describe("Codex compaction and incoming prompts (local e2e)", () => {
for (const behavior of ["send_agent_prompt default", "steer"] as const) {
test.runIf(isCodexInstalled())(
`${behavior}: messages from other agents during auto-compaction do not restart it (#5728)`,
async () => {
const cwd = mkdtempSync(path.join(os.tmpdir(), "codex-compact-cwd-"));
const codexHome = mkdtempSync(
path.join(os.tmpdir(), "codex-compact-home-")
);
const server = await startServer();
const logger = createTestLogger();
const storage = new AgentStorage(path.join(cwd, "agents"), logger);
const manager = new AgentManager({
clients: { codex: new CodexAppServerAgentClient(logger) },
registry: storage,
logger,
});
let agentId: string | undefined;
try {
writeConfig(codexHome, server.url);
vi.stubEnv("CODEX_HOME", codexHome);
const created = await manager.createAgent(
{ provider: "codex", cwd, modeId: "auto", model: "mock-model" },
undefined,
{ workspaceId: undefined }
);
agentId = created.id;
const id = agentId;
await manager.runAgent(id, "first turn");
const send = (text: string) =>
sendPromptToAgent({
agentManager: manager,
agentStorage: storage,
agentId: id,
prompt: text,
...(behavior === "steer"
? { activeTurnBehavior: "steer" as const }
: {}),
logger,
});
const compactions = () =>
server.requests.filter((r) => r.compaction).length;
await send("second turn");
await waitFor(() => compactions() === 1, "first compaction request");
// Two more messages arrive from other agents while it compacts.
await send("update from agent A");
await new Promise((resolve) => setTimeout(resolve, 1500));
await send("update from agent B");
await new Promise((resolve) => setTimeout(resolve, 1500));
// Each message that replaced the turn aborted the compaction and started another.
expect(compactions()).toBe(1);
server.releaseCompactions();
await waitFor(
() => manager.getAgent(id)?.lifecycle === "idle",
"agent idle",
20_000
);
// Every message reaches Codex and the chat, once compaction finishes.
const delivered = server.requests
.filter((request) => !request.compaction)
.map((request) => request.body)
.join("\n");
const chat = manager
.getTimeline(id)
.flatMap((item) =>
item.type === "user_message" ? [item.text] : []
);
for (const text of [
"second turn",
"update from agent A",
"update from agent B",
]) {
expect(delivered).toContain(text);
expect(chat).toContain(text);
}
expect(compactions()).toBe(1);
} finally {
if (agentId && manager.getAgent(agentId))
await manager.closeAgent(agentId);
await storage.flush();
vi.unstubAllEnvs();
await server.close();
rmSync(cwd, { recursive: true, force: true });
rmSync(codexHome, { recursive: true, force: true });
}
},
60_000
);
}
});
@@ -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;
@@ -227,6 +227,95 @@ describe("OMP CLI runtime", () => {
expect(eventTypes).toEqual(["notice"]);
});
test("emits agent_end for a run that contains an OMP developer message", async () => {
const child = createOmpChild();
const session = await createRuntime(child).startSession({ cwd: "/workspace/project" });
const eventTypes: string[] = [];
session.onEvent((event) => eventTypes.push(event.type));
// OMP 18.5 injects a developer reminder mid-run when a tool call matches a rule.
const reminder = {
role: "developer",
content: [
{ type: "text", text: '<system-reminder reason="rule_violation">…</system-reminder>' },
],
attribution: "agent",
timestamp: 2,
};
child.stdout.write(`${JSON.stringify({ type: "message_end", message: reminder })}\n`);
child.stdout.write(
`${JSON.stringify({
type: "agent_end",
messages: [
{ role: "user", content: [{ type: "text", text: "Write tiny.ts" }], timestamp: 1 },
reminder,
{
role: "assistant",
content: [{ type: "text", text: "Done" }],
stopReason: "stop",
timestamp: 3,
},
],
})}\n`,
);
expect(eventTypes).toEqual(["message_end", "agent_end"]);
});
test("emits agent_end for a run that contains every OMP message role Paseo does not render", async () => {
const child = createOmpChild();
const session = await createRuntime(child).startSession({ cwd: "/workspace/project" });
const eventTypes: string[] = [];
session.onEvent((event) => eventTypes.push(event.type));
// A prompt that @-mentions a file makes OMP add a fileMention message to the run.
const fileMention = {
role: "fileMention",
files: [{ path: "notes.md", content: "# Notes\n", lineCount: 1 }],
timestamp: 2,
};
child.stdout.write(`${JSON.stringify({ type: "message_end", message: fileMention })}\n`);
child.stdout.write(
`${JSON.stringify({
type: "agent_end",
messages: [
{ role: "user", content: [{ type: "text", text: "Read @notes.md" }], timestamp: 1 },
fileMention,
{
role: "pythonExecution",
code: "print(1)",
output: "1\n",
cancelled: false,
truncated: false,
timestamp: 3,
},
{
role: "hookMessage",
customType: "hook",
content: "hook output",
display: false,
timestamp: 4,
},
{ role: "branchSummary", summary: "Earlier branch", fromId: "entry-1", timestamp: 5 },
{
role: "compactionSummary",
summary: "Earlier turns",
tokensBefore: 1000,
timestamp: 6,
},
{
role: "assistant",
content: [{ type: "text", text: "Done" }],
stopReason: "stop",
timestamp: 7,
},
],
})}\n`,
);
expect(eventTypes).toEqual(["message_end", "agent_end"]);
});
test("lists commands through get_available_commands", async () => {
const child = createOmpChild();
const commandTypes: string[] = [];
@@ -352,6 +441,31 @@ describe("OMP CLI runtime", () => {
await expect(session.prompt("hello")).resolves.toEqual({ requestId: "req_1" });
});
test("reports OMP 18.3's late prompt rejection as a failed prompt result", async () => {
const child = createOmpChild();
onOmpCommand(child, (command) => {
if (command.type !== "prompt") return;
const response = { id: command.id, type: "response", command: "prompt" };
const rejection = { ...response, success: false, error: "No API key found for anthropic." };
child.stdout.write(
`${JSON.stringify({ ...response, success: true })}\n${JSON.stringify(rejection)}\n`,
);
});
const session = await createRuntime(child).startSession({ cwd: "/workspace/project" });
const events: unknown[] = [];
session.onEvent((event) => events.push(event));
await expect(session.prompt("hello")).resolves.toEqual({ requestId: "req_1" });
expect(events).toContainEqual({
type: "prompt_result",
id: "req_1",
agentInvoked: false,
status: "error",
error: { message: "No API key found for anthropic." },
});
});
test("negotiates RPC protocol v2 when OMP advertises it", async () => {
const child = createOmpChild({ supportedProtocolVersions: [1, 2] });
const commands: Record<string, unknown>[] = [];
@@ -27,7 +27,7 @@ import {
OmpModelsResultSchema,
OmpPromptAckSchema,
OmpRpcCommandSchema,
OmpRuntimeEventSchema,
parseOmpRuntimeEvent,
OmpSessionStateSchema,
OmpSessionStatsSchema,
type OmpThinkingLevel,
@@ -121,9 +121,9 @@ class OmpCliRuntimeSession implements OmpRuntimeSession {
private readonly launchEnvironment: Record<string, string>,
) {
process.onMessage((message) => {
const event = OmpRuntimeEventSchema.safeParse(message);
if (event.success) {
this.emit(event.data);
const event = parseOmpRuntimeEvent(message);
if (event) {
this.emit(event);
}
});
process.onExit(({ error }) => {
@@ -41,6 +41,13 @@ const OmpUserMessageSchema = z
content: z.union([z.string(), z.array(z.union([OmpTextContentSchema, OmpImageContentSchema]))]),
})
.passthrough();
// OMP injects hidden developer messages, such as rule-violation reminders, into a run.
const OmpDeveloperMessageSchema = z
.object({
role: z.literal("developer"),
content: z.union([z.string(), z.array(z.union([OmpTextContentSchema, OmpImageContentSchema]))]),
})
.passthrough();
const OmpCustomMessageSchema = z
.object({
role: z.literal("custom"),
@@ -79,13 +86,30 @@ const OmpBashExecutionMessageSchema = z
timestamp: z.number(),
})
.passthrough();
// Roles Paseo does not render. They are listed so that a frame carrying one, such as
// agent_end, still parses; every role in OMP's RPC AgentMessage must appear in the union.
const OmpPythonExecutionMessageSchema = z
.object({ role: z.literal("pythonExecution") })
.passthrough();
const OmpHookMessageSchema = z.object({ role: z.literal("hookMessage") }).passthrough();
const OmpBranchSummaryMessageSchema = z.object({ role: z.literal("branchSummary") }).passthrough();
const OmpCompactionSummaryMessageSchema = z
.object({ role: z.literal("compactionSummary") })
.passthrough();
const OmpFileMentionMessageSchema = z.object({ role: z.literal("fileMention") }).passthrough();
export const OmpAgentMessageSchema = z.discriminatedUnion("role", [
OmpUserMessageSchema,
OmpDeveloperMessageSchema,
OmpCustomMessageSchema,
OmpAssistantMessageSchema,
OmpToolResultMessageSchema,
OmpBashExecutionMessageSchema,
OmpPythonExecutionMessageSchema,
OmpHookMessageSchema,
OmpBranchSummaryMessageSchema,
OmpCompactionSummaryMessageSchema,
OmpFileMentionMessageSchema,
]);
export const OmpModelThinkingSchema = z
@@ -480,6 +504,8 @@ export const OmpRuntimeEventSchema = z.discriminatedUnion("type", [
type: z.literal("prompt_result"),
id: z.string().optional(),
agentInvoked: z.boolean().optional(),
status: z.string().optional(),
error: z.object({ message: z.string() }).passthrough().optional(),
})
.passthrough(),
z.object({ type: z.literal("process_exit"), error: z.string() }).passthrough(),
@@ -609,6 +635,36 @@ export type OmpSubagentEventPayload = z.infer<typeof OmpSubagentEventPayloadSche
export type OmpAssistantMessageEvent = z.infer<typeof OmpAssistantMessageEventSchema>;
export type OmpAgentSessionEvent = z.infer<typeof OmpAgentSessionEventSchema>;
export type OmpRuntimeEvent = z.infer<typeof OmpRuntimeEventSchema>;
/**
* COMPAT(ompLatePromptRejection): added in v0.11.0, remove after 2027-04-05 once the
* minimum supported OMP version emits prompt_result errors.
* OMP 18.3 reports a prompt it rejects after acknowledging it, such as one whose model has
* no API key, only as a second failed `response` frame for the prompt id. Later releases
* report the same outcome as a `prompt_result` with `status: "error"`.
*/
const OmpLatePromptRejectionSchema = z.object({
type: z.literal("response"),
command: z.literal("prompt"),
id: z.string(),
success: z.literal(false),
error: z.string(),
});
export function parseOmpRuntimeEvent(message: unknown): OmpRuntimeEvent | null {
const rejection = OmpLatePromptRejectionSchema.safeParse(message);
if (rejection.success) {
return {
type: "prompt_result",
id: rejection.data.id,
agentInvoked: false,
status: "error",
error: { message: rejection.data.error },
};
}
const event = OmpRuntimeEventSchema.safeParse(message);
return event.success ? event.data : null;
}
export type OmpTodoItem = z.infer<typeof OmpTodoItemSchema>;
export type OmpTodoPhase = z.infer<typeof OmpTodoPhaseSchema>;
export type OmpTodoReminderEvent = z.infer<typeof OmpTodoReminderEventSchema>;
@@ -294,6 +294,14 @@ export class OmpHarness {
return await run;
}
async runPromptWithoutTurnOnNextRuntime(input: string, requestId: string): Promise<unknown> {
const session = this.requireSession();
this.omp.queueSessionSetup((runtime) => {
runtime.promptAck = { requestId, agentInvoked: false };
});
return await session.run(input);
}
async runPromptWithoutTurn(input: string): Promise<unknown> {
const session = this.requireSession();
this.omp.latestSession().promptAck = { agentInvoked: false };
@@ -329,6 +337,31 @@ export class OmpHarness {
return { completed: () => isCompleted, completion };
}
async runPromptRejectedBeforeAgentRuns(
input: string,
error: string,
order: "result after ack" | "result before ack",
): Promise<unknown> {
const session = this.requireSession();
const runtime = this.omp.latestSession();
runtime.promptAck = { requestId: "prompt-rejected" };
const rejection = {
type: "prompt_result",
id: "prompt-rejected",
agentInvoked: false,
status: "error",
sessionSettled: true,
error: { message: error, retryable: false },
} as const;
const promptStarted = runtime.nextPrompt();
const run = session.run(input);
if (order === "result before ack") runtime.emit(rejection);
await promptStarted;
await waitForImmediate();
if (order === "result after ack") runtime.emit(rejection);
return await run;
}
async runPromptAfterCorrelatedTrueResult(
input: string,
output: string,

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