Compare commits

...
Author SHA1 Message Date
Mohamed Boudra dd25057bbb chore(release): cut 0.11.3 2026-10-15 18:52:40 +02:00
Mohamed Boudra ed53a71939 docs(changelog): draft 0.11.3 2026-10-15 18:48:31 +02:00
Mohamed Boudra 84c0239448 Refine provider catalog buttons and show Add errors as toasts (#7030)
* fix(app): refine provider catalog actions and error feedback

Use compact row tiles and the shared error toast so failed Add actions remain visible while browsing the catalog. Point Hoplite installation instructions at the CLI install section.

* test(app): supply toast context in provider settings tests

Import the toast API independently of its renderer so the provider section only depends on the context it consumes.

(cherry picked from commit 2d86b94b2f)
2026-10-15 18:26:49 +02:00
Mohamed Boudra 5d6a6bda1f feat(providers): add opt-in Hoplite and searchable provider grid (#6953)
Preserve the bundled Hoplite ACP adapter and provisioning transformer, activate it only after Add, and provide a searchable provider grid. Restore the contributor's original icon without the copyright mark.

Co-authored-by: Odin Glynn-Martin <odin.glynn@icloud.com>
(cherry picked from commit 16bb179e67)
2026-10-15 18:26:49 +02:00
CtrlCreeper cb86d02bd0 fix(app): only offer Remove for providers defined in config.json (#6568)
* fix(app): only offer Remove for providers defined in config.json

Plugin-registered providers (Antigravity, Muse Code) share source: "custom"
with config-defined ones, so Settings → Providers offered Remove on them.
The removeProviders patch only deletes config.json entries, so the action
succeeded silently while the plugin re-registered the provider.

Gate the Remove menu on the provider having an extends entry in config —
the actual definition custom providers live behind. Plugin providers keep
their enable/disable switch.

Fixes #6349

* Address review: named ownership check, E2E coverage for plugin providers

- Name the config-owned provider check instead of combining three
  conditions inline in canRemove.
- Replace the JSDOM component tests with a Playwright case in the
  existing provider-removal suite: a plugin-registered provider renders
  without the Remove action and survives a removeProviders patch.

(cherry picked from commit e8108d9092)
2026-10-15 18:26:49 +02:00
Mohamed Boudra 7595325095 chore(release): cut 0.11.2 2026-10-09 16:10:28 +02:00
Mohamed Boudra f1b097a563 docs(changelog): describe iOS text clipping fix for 0.11.2 2026-10-09 16:09:44 +02:00
Mohamed Boudra 76804cba66 Revert "fix(app): reflow iPad chat text after pane resize (#5716)"
This reverts commit 5715b5ba62.
2026-10-09 16:06:53 +02:00
Mohamed Boudra ab10a6694c chore(release): cut 0.11.1 2026-10-07 22:30:39 +02:00
Mohamed Boudra 2a151a4494 docs: changelog for 0.11.1 2026-10-07 22:23:15 +02:00
Mohamed Boudra 9fcae41c98 feat(claude): add Haiku 5.5 with its model capabilities (#6332) 2026-10-07 21:00:11 +01:00
Mohamed Boudra b352dad2e3 fix(website): put Plugins first in the header 2026-10-07 21:27:17 +02:00
paseo-bot[bot] 1ae979d3d0 Subagents that finish while the app is disconnected no longer stay "working" (#6316)
* fix(app): catch up provider subagents after the update feed reconnects

Subagent updates are not replayed across a reconnect, so a child that
finished while the app was disconnected stayed "working" until one
subagent was opened and its parent was listed again. Re-list every
parent the app holds once the update feed resubscribes.

Closes #6109

* fix(app): resync a subagent parent even when its first list failed

A parent's subagent track lists the parent once on mount. When that list
failed because the app was disconnected, the parent was never tracked,
so the reconnect resync skipped it and children that later arrived via
live updates stayed stale after the next drop. Track the parent when its
list is requested instead of when it succeeds.

* test(website): expect the plugin directory links now that they are on

1b88f1612 turned PLUGINS_LINKED on, but the e2e test still asserted the
directory was unlinked, failing the typecheck job on main and every PR.
Assert the navigation, footer, and Browse plugins links point at /plugins.
The header check now targets the navigation landmark the homepage renders;
the banner locator matched nothing, so it only passed while asserting zero.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-07 18:30:20 +02:00
paseo-bot[bot] d46164ce0d fix(claude): keep long-running tool calls out of the subagents track (#6308)
Claude Code sends a tool_progress heartbeat every 30 s while a tool is
still running, with that tool's own id as parent_tool_use_id. The Claude
adapter routed any frame with a parent id to the sidechain tracker, so
a main-thread call that ran past 30 s (MCP, WebFetch, WebSearch) became
a "Claude subagent" row with a child action per heartbeat that never
completes, and its card was relabeled Task.

A heartbeat now names no sidechain, so it never reaches subagent
routing.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-07 17:17:17 +02:00
Mohamed Boudra a591320409 docs(changelog): announce the plugin registry in 0.11.0 2026-10-07 16:38:38 +02:00
paseo-bot[bot] ee6deda079 Show a Claude agent's history when its transcript is under another project folder (#6301)
* fix(claude): load history from a transcript under another project folder

Claude Code resumes a session by id from any working directory and keeps
writing to the transcript in the project folder where the session started.
When the agent's cwd encodes to a different folder (a moved project, a
migrated daemon, a Windows binary under WSL), the agent kept working but
reopened with an empty timeline. Look the transcript up by session id
across the config dir's project folders when it is not where cwd points.

Refs #4540

* fix(claude): report a failed project folder listing instead of an absent transcript

Take the transcript lookup's inputs as one object, and rethrow read errors other than a missing projects folder so the history load logs them as a failure.

* test(claude): skip the unreadable projects folder case when running as root

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-07 16:09:34 +02:00
paseo-ai[bot] 0555c877d0 fix: update lockfile signatures and Nix hash [skip ci] 2026-10-07 13:49:31 +00:00
Mohamed Boudra b6454d7ea4 fix(website): show paseo plugin add as the registry install command 2026-10-07 15:33:25 +02:00
Mohamed Boudra 1b88f16124 feat(website): link the public plugin registry 2026-10-07 15:33:25 +02:00
Mohamed Boudra 1fae8e89f7 fix(website): mute footer column titles by shade instead of weight 2026-10-07 15:33:25 +02:00
Mohamed Boudra 22488d4502 chore(release): cut 0.11.0 2026-10-07 15:27:08 +02:00
Mohamed Boudra 2a5264c188 docs: changelog for 0.11.0 2026-10-07 15:05:53 +02:00
8ddeb79c8d Claude agents stay idle while background helpers work, and send or Stop no longer kills them (#6295)
* fix(claude): open autonomous turns only when Claude starts a turn

Paseo guessed from frame shape whether the main session had started work
on its own, and only a result could close the turn that guess opened. A
background subagent's task frames, its own background jobs'
task_notification and rate limit events all arrive with no parent tool
use id, so they looked like main-session work and opened a turn no result
would ever close. The agent then reported "running" while its main
session sat idle, and the app's message queue never drained.

Claude brackets every main-session turn itself: system/init opens it and
exactly one result closes it, whether a prompt, a wake-up after background
work, a slash command or an API error started it. Background subagents
never emit either on the main stream. An autonomous turn now opens only on
that init, and only when no foreground turn is open. The frame heuristic
is removed.

- Paseo launches the CLI with CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS=1,
  added as an overlay in buildSdkEnv so the SDK and the usage probe share
  one env object. An "idle" session state closes an autonomous turn as a
  safety net, never a foreground one. "running" opens nothing: Claude Code
  2.1.280 stays "running" for as long as any background subagent is
  alive. State frames never reach the timeline.
- An init clears a leftover pendingInterruptAbort. A new turn has begun,
  so the interrupted one is over, and a stale flag would swallow the new
  turn's result.

Tests that opened autonomous turns from bare frames now send the init
Claude sends. New coverage: helper frames never open a turn while the main
session is idle, a wake-up turn opens on init and closes on its result, a
folded steer keeps one turn while a turn Claude starts after the result
gets its own, an idle closes a turn no result closed, session state never
opens a turn, and the env flag reaches the SDK launch.

docs/agent-lifecycle.md records that only system/init and result bracket
a main-session turn, since the code cannot tell a reader why frame shape
fails.

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

* fix(claude): interrupt Claude only while its main turn runs, and spare helpers

Every Stop, and every send that replaces a running turn (paseo send, MCP
send_agent_prompt, voice, the app's queue drain, Send now), ends in
query.interrupt(). Without the SDK's perTaskStopAffordance declaration
that call kills every running background subagent, even when the main
session is idle and there is nothing of ours to stop.

- Declare perTaskStopAffordance, so an interrupt aborts only the main
  turn, like Esc in Claude Code. Claude stops a helper itself with its
  TaskStop tool.
- Track whether a main-session turn is in flight from Claude's own
  brackets: set by system/init and by a pushed message's
  command_lifecycle "started" (/compact works for seconds before its
  init), cleared by the result or an idle session state.
- Call query.interrupt() and set pendingInterruptAbort only when that
  turn is in flight, or the cancelled turn's own message has already been
  started. Otherwise withdraw our not-yet-started messages with
  cancel_async_message instead: an idle interrupt reaches only helpers,
  and with no result to follow it the flag would linger.
- Track the foreground prompt with the queued steers until Claude starts
  it, so Stop withdraws a message still waiting in the queue. A turn
  stopped while the Claude process is still starting never sends it.
- Accept command_lifecycle "cancelled" as terminal, so a cancelled steer
  no longer waits to be withdrawn or keeps denying permission cards.

Interrupt tests now wait for the pump to read the init before stopping,
since that is when Paseo learns a turn is in flight. New coverage: a stop
before Claude starts the message withdraws it without interrupting, a
stop while only helpers run never reaches Claude, a stop mid-turn
interrupts once and drops the aborted result, a new turn clears a flag
no result will consume, the SDK option is passed, and a cancelled steer
is not withdrawn again.

docs/providers.md described the old unconditional interrupt; it now states
when the Claude adapter interrupts and why it declares the affordance.

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

* fix(claude): keep a helper spawned in the background out of a turn's cancel

When a turn is canceled, Paseo terminalizes the subagents that were
running in its foreground and skips the backgrounded ones, which outlive
it. It only learned a child was backgrounded from task_updated's
patch.is_backgrounded, which Claude sends when a foreground task moves to
the background later. A child spawned in the background carries the flag
on task_started instead, and a resumed subagent always does, so the
track marked those helpers "canceled" while they kept working.

Read is_backgrounded from task_started as well. docs/agent-lifecycle.md
said task_updated was the only signal; it now names both.

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

* fix(app): drain queued messages as steers

The composer queue drains when an agent goes idle. It sent each message
with no activeTurnBehavior, which the daemon treats as "interrupt": if
the agent had just started a turn of its own (a Claude wake-up after
background work finished), the drained message stopped that turn and
replaced it.

Send drained messages with activeTurnBehavior "steer". A drain that races
such a turn folds into it; with no turn running the daemon starts a
normal one, as before. "steer" is an existing protocol value, so older
daemons need nothing new.

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

* test(claude): cover background helpers against real Claude

Every other test in this file tells Claude "Do not use a background
task", so a message sent or a Stop pressed while a backgrounded subagent
runs was never exercised. Four real-Claude scenarios cover it:

- "a background subagent keeps running after a message is sent while it
  works": one helper, the main turn ends, the parent is not reported
  running, a message in that window is answered, and the helper is never
  read as canceled and completes.
- "a helper's own nested background job does not phantom-run the parent
  or kill the helper": a helper starts its own background Bash job, then
  keeps working in the foreground. The parent stays idle across the
  nested job's completion (sampled for 30 s around a 5 s job), a message
  sent in that window is answered, and the helper reports CHILD_DONE.
- "the app's default steer survives a backgrounded helper while the main
  session works": the app's default send behavior (steer) does not kill
  a helper when it interrupts the main session's own turn.
- "Stop cancels the main session's own turn but spares its background
  helper": the same for Stop.

The wake-up turn Claude starts after a helper finishes lands a moment
after the helper reads completed (0.1 to 5 s in recorded traces), and the
daemon announces it as a pushed "running" agent_update, not as a
turn_started stream event. The checks poll for it and read the final idle
once the wake-up settles. waitForFinish can return a stale "idle" right
after a send, and the push stream can miss a message's final text when it
resolves, so the tests read fetchAgentTimeline like the rest of the file.
Helper status comes from agent.provider_subagents.list, since agent_stream
carries no provider_subagent events.

The scenarios need the previous commits together. Run against the frame
heuristic this branch removes, the nested-job test fails: the parent
flips to running once the nested job finishes and stays there.

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

* fix(claude): interrupt a stopped prompt Claude has already taken

Stop before Claude announces a prompt started withdraws it with
cancel_async_message instead of interrupting. Claude can dequeue a
message for its next turn before announcing it, and withdrawing it is
then a no-op that reports cancelled: false, so the stopped prompt kept
running. Interrupt whenever a message could not be withdrawn.

Co-authored-by: Isak Persson <isak.a.persson@gmail.com>

* fix(claude): withdraw a stopped prompt only once

When the withdrawal of a stopped prompt failed, the interrupt path
withdrew a second time, which could cancel a prompt sent while the first
withdrawal was in flight. Withdraw once and interrupt on failure.

The real-Claude steer scenario now waits for the background helper to
exist before steering, so it proves the helper survives the steer.

Co-authored-by: Isak Persson <isak.a.persson@gmail.com>

---------

Co-authored-by: Isak Persson <isak.a.persson@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-07 14:08:48 +02:00
paseo-bot[bot] c90587e5b7 Keep a plugin search term typed while the website page is still loading (#6293)
* test(website): reproduce plugin search dropping a term typed before hydration

Typing in the plugin search box before the page's scripts run leaves the
term in the box but not in React state, so Enter searches without it.

* fix(website): keep a plugin search term typed before the page loads

Text typed into the plugin search box before hydration stayed in the box
but not in component state, so Enter searched without it and the clear
button never appeared. Adopt the box's value once the page hydrates.

Closes #6286

* test(website): cover live browse search typed before the page loads

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-07 12:49:37 +02:00
Mohamed Boudra 56f4fca9e4 docs(workspaces): guide parallel development and review (#6294) 2026-10-07 11:40:18 +01:00
paseo-bot[bot] 28f7528588 docs: fix the Tailscale Serve command and log rotation defaults (#6284)
The web UI guide gave the pre-1.52 `tailscale serve https / <target>`
form, which current Tailscale rejects with "invalid number of
arguments". The configuration page said logs keep 2 files in total,
while the daemon keeps 3 rotated files next to the active one.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-07 09:51:17 +02:00
paseo-bot[bot] f13bb2136c fix(server): keep accented names on downloaded files (#6281)
A file name that fits in Latin-1, like café.txt, went out as a raw byte in
Content-Disposition filename= with no filename* parameter, and Chromium saved
the file under a name taken from the URL (download.txt). Build the header so
every non-ASCII name carries filename*=UTF-8''… with an ASCII fallback.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-07 08:57:29 +02:00
paseo-bot[bot] 2323ee6844 Keep reporting a Linux daemon as running after its VM resumes from a host sleep (#6279)
* Keep reporting a Linux daemon as running after its VM resumes from a host sleep

A VM paused while its host sleeps keeps its uptime, but its wall clock
catches up on resume. The boot instant derived from the two then lands
after the running supervisor's lock, so daemon status reported it as
stopped and daemon stop released its lock without stopping it.

The lock now records Linux's boot id, and a lock that carries one is
judged by it. Locks without one keep the wall-clock check.

Closes #6019

* Give the OMP idle-deadline test a deadline a slow runner cannot pass before the first wait

With a 1 ms deadline, the gate's first check, made synchronously when the
turn ends, could already be past it on a loaded Windows runner. The turn
then failed before the test's first scheduler wait, and the test timed out.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-07 07:51:12 +02:00
1e00f452ea Download files whose names have non-ASCII characters (#6278)
* fix(server): encode Unicode download filenames in Content-Disposition

* test(server): cover Korean download filenames

* test(server): validate download token and agent ID formats

* fix(server): set the download header through Express instead of a new dependency

The download route already runs on an Express response, and res.attachment()
builds Content-Disposition with the same content-disposition encoder that
Express ships. Use it and drop the direct content-disposition dependency and
its type package. The header values the download tests assert are unchanged.

Co-authored-by: jasonhnd <3293256+jasonhnd@users.noreply.github.com>

---------

Co-authored-by: Dillon <dillonzq@outlook.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: jasonhnd <3293256+jasonhnd@users.noreply.github.com>
2026-10-07 06:59:53 +02:00
paseo-bot[bot] a162e1075f fix(acp): apply configured models the agent does not advertise (#6273)
* fix(acp): apply configured models the agent does not advertise

A model added through a provider's models or additionalModels was listed
in the picker, but an ACP session only accepted ids the agent advertised
in session/new, so the agent silently ran on its default model. The
registry now passes configured model ids to the session, which applies
them through session/set_model. Unconfigured, unadvertised ids are still
skipped with a warning.

* fix(acp): send configured models when the agent advertises none

Also covers the resume path in the regression test.

* test(acp): keep configured-model tests in the ACP agent suite

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-07 06:15:19 +02:00
paseo-bot[bot] 8f1091da64 Let a Claude agent leave Auto mode when Claude Code uses Bedrock or Vertex (#6272)
* fix(claude): let an agent leave Auto mode when Claude Code uses Bedrock or Vertex

Changing the mode of a Claude agent whose process is not running starts the
process first. It was started in the agent's current mode, so an agent in Auto
on Bedrock or Vertex failed the Auto check before the new mode applied, and
every mode change and message failed the same way.

Start the process in the requested mode instead.

Closes #5878

* fix(claude): relaunch a query whose mode change Claude Code rejected

A mode change that starts the query now launches it in the requested mode. If
Claude Code then rejects the mode, the agent keeps its previous mode, so mark
that query for relaunch instead of reusing it in the rejected mode.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-07 04:55:01 +02:00
Bae Seongmin 4dd0e50e78 fix(server): time out stalled encrypted relay handshakes (#5648) 2026-10-07 03:53:18 +02:00
paseo-bot[bot] dc785bb29a Keep both subagent cards labeled when Claude launches two background agents together (#6268)
* fix(server): keep a subagent card labeled when two background agents launch together

The daemon's stream coalescer kept only the last update per tool call
inside its 60 ms window. When Claude launches two background Agent calls
in one message, the first child's task_started arrives a few ms after the
second call starts streaming, so its labeled card is buffered and then
overwritten by the unlabeled completed Agent update. The coalescer now
folds updates with the same merge the timeline projection applies, so a
labeled detail is not replaced by an unknown one.

Closes #6252

* refactor(server): compute the merged tool call before building the coalescer entry

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-07 02:54:09 +02:00
Mohamed Boudra 93bfb34e21 feat(website): copy states what Paseo does, plugins section and comparison tables (#6270)
The homepage, README, sponsor copy and comparison pages now say plainly
what Paseo is: a one-click desktop app with the daemon bundled, a native
mobile app with full parity, parallel agents in worktrees, and plugins
that add panels and functionality on desktop, web and mobile.

Every comparison page uses one table with the same rows so readers can
compare products row by row. The product columns use a fixed layout so
column widths no longer follow the longest cell.

Sponsor copy opens with the project's size before the one-person fact,
so a reader does not take "one person" to mean a side project.
2026-10-07 00:59:14 +01:00
Mohamed Boudra 2d85fe4e04 feat(plugins): declare display metadata in plugin manifests (#6263)
* feat(plugins): declare display metadata in plugin manifests

* style(website): format plugin page description

* fix(plugins): preserve default names and clarify registry follow-up

* test: fix stale plugin card selectors and terminal marker race
2026-10-06 23:18:24 +01:00
Mohamed Boudra 1463396ebc fix(website): plugin search ranks name matches first
Search filtered by substring and the browse page then sorted by installs,
so an exact name like "OMP" landed behind plugins that only mentioned it
in a description. Results now rank by the field matched (name, author,
description), then match quality from the shared text matcher, with the
tab's order breaking ties.
2026-10-06 23:50:27 +02:00
Mohamed Boudra d4ea77b910 fix(website): plugin install command block fits the command 2026-10-06 23:26:35 +02:00
Mohamed Boudra f715221de8 feat(website): plugins page copy describes browsing community plugins 2026-10-06 23:26:35 +02:00
Mohamed Boudra aedba5ec27 fix(website): plugin page content spans the same width as the header and footer
The page capped itself at max-w-3xl inside the site shell, so the install command, gallery, and README stopped short of the nav's right edge.
2026-10-06 23:19:16 +02:00
Mohamed Boudra bdc58fb07c feat(website): plugins hero, author and description on cards, install counts with an icon
The plugins page opens with the directory copy, plugin and author counts,
Submit a plugin and Read the docs, and search. Featured and What's new cards
show the description and the author with avatar; the trailing slot carries
installs on Featured and the added date on What's new. Every install count
on the site is the same download icon and number.
2026-10-06 23:18:20 +02:00
Mohamed Boudra 3f1a04304d website: plugin gallery peeks a third tile, viewer steps with arrows (#6261)
* website: gallery peeks a third tile, viewer steps with arrows

With three or more items the strip sizes tiles at 42% from md up so a
third tile is cut at the right edge. The viewer gains Previous and Next
buttons and Left/Right arrow keys, wrapping at the ends; moving away
from a video unmounts it, which stops playback.

* website: format the plugins directory route
2026-10-06 21:49:25 +01:00
Mohamed Boudra 50cf244e86 website e2e: expect the current Featured subtitle 2026-10-06 22:31:42 +02:00
Mohamed Boudra aaeefdc00b website: 80px between the site header and the page content 2026-10-06 22:30:40 +02:00
Mohamed Boudra c5deaa0516 Revert "website: plugins section spacing with margins, not flex gaps"
This reverts commit 72c180aaf7.
2026-10-06 22:30:39 +02:00
Mohamed Boudra 72c180aaf7 website: plugins section spacing with margins, not flex gaps
PluginSection takes its header as a prop and puts the content 32px
below it with a margin. Sections on the index are 56px apart via
space-y. The browse page h1 sits 32px above the sort tabs, matching
the index headings.
2026-10-06 22:23:50 +02:00
Mohamed Boudra 66b2567be3 Featured: subtitle and a See all plugins link 2026-10-06 22:20:10 +02:00
Mohamed Boudra cb2b569bbd Featured subtitle: hand-picked plugins that show what is possible 2026-10-06 22:15:58 +02:00
paseo-bot[bot] 2ecf3ba783 Keep an archived agent archived when a send to it fails (#6255)
* Keep an archived agent archived when a send to it fails

Sending a message to an archived agent unarchives it before loading the
session. When loading failed, for example because the agent's working
directory was removed, the agent stayed unarchived. The send now restores
the original archive timestamp when it fails, matching resume_agent.

Closes #6245

* Restore the archive only when the send cannot load the agent

Concurrent sends share one load, so a failed load leaves no session for
the restore to close. A failure after the load no longer re-archives an
agent another send may already be running.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-06 21:56:27 +02:00
Mohamed Boudra 4515061f88 website: submit-based plugin search on the home page, back button works on browse (#6256)
* website: submit-based plugin search on the home page, back button works on browse

* website: cover Forward in the browse search history test
2026-10-06 20:53:42 +01:00
Mohamed Boudra 3c478e5967 website: plugins page spacing, featured section, media with video, and viewer (#6254)
* website: match left and right edge padding on ranked plugin rows

The rank sat right-aligned in a fixed 16px column and the row had 8px of
padding, so a single digit started ~15.6px from the row edge while the
install count ended 8px from it. The rank now sits at the row's padding,
which is 16px on both sides, and the list's negative margin matches so
the rank still lines up with the section heading. Tabular figures keep
the tile column aligned without the fixed width.

* website: give plugins section headings room above their content

Section headings sat 16px above their cards while sections sat 56px
apart, so a heading read as closer to the section above it. A shared
PluginSection now owns the header-to-content gap (32px), used by the
index sections and by the browse results under the sort tabs.

* website: center the Build your own plugin card

The heading, description, and buttons were left-aligned in a full-width
card, leaving most of it empty. Center them and keep the description at
its readable measure.

* website: order What's new by first-listing date

newestFirst and addedAgo read submittedAt. The registry publishes the
first-listing date as publishedAt, so What's new now sorts and dates by
it. The fixture's publishedAt values become first-listing dates.

* website: plugin media with video tiles and a click-to-view viewer

The registry's published screenshots field becomes media and may hold
mp4 and webm videos. Cards and link previews use the first image, the
thumbnail route serves only image entries, and the detail gallery shows
videos as tiles alongside images. Any tile opens one viewer. An image
fits the viewport but never exceeds its natural size. A video plays with
controls and scales to the largest size that fits, small sources included.

The daemon resolver now validates only the ID and artifact it installs.

* server: drop the unreleased pluginRegistryEnabled config key

The key only existed in unreleased betas and nothing reads it. The
config schema is strict, so a config that still sets it now fails
validation and names the key.

* website: Featured section on the plugins index

The registry index gains a required featured list of plugin IDs. The
index shows those plugins above What's new, in the registry's order,
skipping IDs that are not listed, and hides the section when none are.
The index sections now share one container for their spacing.
2026-10-06 20:53:34 +01:00
paseo-bot[bot] 05637a7f05 Show a Claude subagent's auto-mode report as markdown instead of a raw SubagentHandback card (#6248)
* Show a Claude subagent's handback report as its final message

Since Claude Code 2.1.271, a subagent in auto mode delivers its report
through a SubagentHandback tool call instead of its final text. Paseo
showed that call as a generic tool card with the report as escaped JSON.
Live sidechain frames and replayed history now map the call to the
subagent's final assistant message and hide its acknowledgement.

Closes #6241

* Keep a handback report's leading whitespace and hide its acknowledgement by call id

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-06 20:54:16 +02:00
paseo-ai[bot] 13d21f60f1 fix: update lockfile signatures and Nix hash [skip ci] 2026-10-06 17:40:48 +00:00
Mohamed Boudra fc3b2c4eb7 Sanitize plugin overview rendering: no raw HTML, https links only, no relative links (#6244)
* fix(plugins): restrict overview content to safe Markdown and HTTPS destinations

Registry overviews are author-controlled content read away from their repositories. Apply a shared presentation policy instead of the documentation renderer so relative links, non-HTTPS URLs, raw HTML, and author styles never become active overview content.

* fix(plugins): choose the first allowed screenshot for previews
2026-10-06 18:31:53 +01:00
paseo-bot[bot] a9fef4879a Show an archived ACP agent's history after its worktree is removed (#6228)
* Show an archived ACP agent's history after its worktree is removed

Opening an archived ACP agent loads its history by resuming the provider
session for reading. The provider process was spawned in the agent's
stored working directory, so once that directory was removed (for
example when its worktree workspace was archived) the spawn failed with
ENOENT and the chat opened empty.

A history load now starts the provider process from the home directory
when the working directory is gone, and still names the original
directory in session/load. An interactive resume keeps requiring the
directory.

Closes #5449

* Only treat a missing working directory as gone for a history load

A stat failure other than a missing path, such as a permission error,
now reaches the caller instead of moving the provider to the home
directory.

* Start the history process from home when the stored path is not a directory

A file left where an archived agent's worktree was made the history load
spawn in it and fail with ENOTDIR.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-06 18:54:59 +02:00
Mohamed Boudra a292b402cf Resolve local plugin directories in the CLI's working directory (#6239)
* fix(cli): resolve local plugin directories from the caller's cwd

Relative plugin sources were sent unchanged, so a daemon started elsewhere inspected the wrong directory. Resolve directory sources and expand the caller's home before sending, preserving subdirectory suffixes and managed source identifiers.

* fix(cli): preserve absolute plugin paths across host platforms

A remote daemon may use another operating system. Recognize absolute POSIX and Windows directory sources before resolving relative paths on the CLI host.
2026-10-06 17:31:57 +01:00
Mohamed Boudra 64c9f263e8 Thumbnail plugin card screenshots through Cloudflare image transformations (#6240)
* perf(website): thumbnail plugin card screenshots

* fix(website): preserve and sandbox plugin screenshot fallbacks
2026-10-06 17:30:18 +01:00
paseo-bot[bot]andDillon 48059aeae3 Show the PR panel's state, activity verbs, and thread badges in the app language (#6225)
* fix(app): show PR panel state, activity verbs, and thread badges in the app language

The PR panel's state label, the verb after each activity author, and the
Resolved and Outdated thread badges were English literals, so they stayed
English in every other language. The state and verb translations already
existed and were unused; the badges get new keys in every locale.

Closes #6214

* fix(app): translate every PR panel verb and state, and phrase them after the author

The zh-CN state and review labels still held English ("Open", "Draft",
"已 review"), and the Spanish and Portuguese verbs were adjectives that
do not read after an author's name. The translation test now checks
each verb and state on its own, so one English label fails it.

Co-authored-by: Dillon <dillonzq@outlook.com>

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Dillon <dillonzq@outlook.com>
2026-10-06 18:03:10 +02:00
Mohamed Boudra 7fd469ae19 Enable plugin registry installs by default (#6224)
* feat(plugins): enable registry installs by default

* fix(plugins): explain explicit Git source for missing registry IDs

* fix(plugins): tailor missing registry guidance to installation

* test(app): sample footer geometry in one browser frame

* fix(plugins): make registry source resolution deterministic

Directory existence must not change the meaning of a registry identifier shared by CLI, RPC, and UI installs. Classify explicit host paths in the source parser and explain recovery from registry transport and revision-selection failures.

* fix(plugins): recognize dot and Windows directory sources

* test(cli): wait for readable daemon ownership before cancellation

The cancellation test raced PID-file creation against its first write and parsed empty JSON in CI. Use the public daemon-instance reader to wait for and capture the supervisor PID before asserting cancellation cleanup.

* test(app): expect missing directory install guidance
2026-10-06 16:52:02 +01:00
paseo-bot[bot] 56d5680742 fix(highlight): keep Astro expressions colored through nested template strings (#6219)
An Astro { } expression containing a template string inside ${} closed at
the first } in that nested string, leaving the rest of the expression
uncolored. Astro and Vue now share one scanner for embedded JavaScript,
which already skips template substitutions.

Closes #6209

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-06 17:03:17 +02:00
Mohamed Boudra 06bde1e57a Scaffold author overviews for plugin listings (#6226)
* feat(plugins): scaffold author listing overviews

* docs(plugins): refine the author overview contract

* docs(plugins): require repository overviews for registry listings

* docs(plugins): keep listing guidance focused on authors
2026-10-06 15:58:26 +01:00
Mohamed Boudra 396a2bc0b2 fix(website): count a plugin install once per IP per hour (#6230)
Repeated install-intent resolves inflated plugin rankings. Deduplicate reports by plugin and hashed Cloudflare IP for one hour, and skip counting when the IP header is absent.
2026-10-06 15:34:15 +01:00
231 changed files with 9293 additions and 1648 deletions
+3
View File
@@ -93,3 +93,6 @@ app.json
# Heap snapshots dumped by --heapsnapshot-near-heap-limit; keep them for debugging.
*.heapsnapshot
# Project-local Serena configuration and memories.
.serena/
+69 -6
View File
@@ -1,12 +1,49 @@
# Changelog
## 0.11.0-beta.5 - 2026-10-06
## 0.11.3 - 2026-10-15
### Added
- Added Hoplite as an opt-in provider in the ACP provider catalog ([#6953](https://github.com/getpaseo/paseo/pull/6953) by [@odinglyn0](https://github.com/odinglyn0))
- Added search to the Add provider catalog ([#6953](https://github.com/getpaseo/paseo/pull/6953), [#7030](https://github.com/getpaseo/paseo/pull/7030))
### Changed
- Changed Add provider failures to show as a toast ([#7030](https://github.com/getpaseo/paseo/pull/7030))
### Fixed
- Fixed Remove being offered for plugin providers such as Antigravity and Muse Code ([#6568](https://github.com/getpaseo/paseo/pull/6568) by [@Ctrl-Creeper](https://github.com/Ctrl-Creeper))
## 0.11.2 - 2026-10-09
### Fixed
- Fixed intermittent text clipping in iOS chats and Markdown previews ([#6445](https://github.com/getpaseo/paseo/pull/6445))
## 0.11.1 - 2026-10-07
### Added
- Added Claude Haiku 5.5 for Claude Code 2.1.293 and newer ([#6332](https://github.com/getpaseo/paseo/pull/6332))
### Fixed
- Fixed Claude tool calls that run longer than 30 seconds, such as WebFetch or MCP tools, showing up as subagents ([#6308](https://github.com/getpaseo/paseo/pull/6308))
- Fixed subagents that finish while the app is disconnected staying "working" until one is opened ([#6316](https://github.com/getpaseo/paseo/pull/6316))
- Fixed a Claude agent's history showing "No activity to display" after a daemon restart when its transcript is in another project folder, such as after moving the daemon or running Windows Claude Code from WSL ([#6301](https://github.com/getpaseo/paseo/pull/6301))
## 0.11.0 - 2026-10-07
Paseo 0.11 adds support for the [plugin registry](https://paseo.sh/plugins). Browse community plugins there, install one with `paseo plugin add owner/slug`, or [submit your own](https://paseo.sh/docs/plugins/publishing#plugin-registry).
**Before upgrading:** `paseo plugin add owner/repo` now installs from the registry, so use `github:owner/repo` to install straight from GitHub and `./path` for a local directory.
### Added
- Added Muse Code as a provider, with model, approval mode, and reasoning effort controls ([#5719](https://github.com/getpaseo/paseo/pull/5719), [#5775](https://github.com/getpaseo/paseo/pull/5775) by [@millerben95](https://github.com/millerben95))
- Added Antigravity as a provider that drives the installed `agy` CLI, always in Full access ([#5714](https://github.com/getpaseo/paseo/pull/5714), [#5795](https://github.com/getpaseo/paseo/pull/5795))
- Added a Usage screen, opened from the sidebar footer, showing each subscription account's quota windows with Refresh ([#5465](https://github.com/getpaseo/paseo/pull/5465), [#5685](https://github.com/getpaseo/paseo/pull/5685), [#5786](https://github.com/getpaseo/paseo/pull/5786), [#5805](https://github.com/getpaseo/paseo/pull/5805), [#5921](https://github.com/getpaseo/paseo/pull/5921))
- Added Usage, opened from the sidebar footer as a dialog on desktop and a sheet on mobile, showing each subscription account's quota windows with Refresh ([#5465](https://github.com/getpaseo/paseo/pull/5465), [#5685](https://github.com/getpaseo/paseo/pull/5685), [#5786](https://github.com/getpaseo/paseo/pull/5786), [#5805](https://github.com/getpaseo/paseo/pull/5805), [#5921](https://github.com/getpaseo/paseo/pull/5921), [#6216](https://github.com/getpaseo/paseo/pull/6216))
- Added an opt-in Usage summary to the sidebar footer that shows the windows you pin ([#5903](https://github.com/getpaseo/paseo/pull/5903), [#5975](https://github.com/getpaseo/paseo/pull/5975), [#6089](https://github.com/getpaseo/paseo/pull/6089))
- Added Usage discovery of ChatGPT logins from Codex CLI, OpenCode, Pi, and OMP ([#5844](https://github.com/getpaseo/paseo/pull/5844))
- Added Usage discovery of Claude logins from the Claude Code credential file, the macOS keychain, Pi, and OMP ([#5844](https://github.com/getpaseo/paseo/pull/5844))
@@ -16,19 +53,26 @@
- Added Fast mode and an Auto thinking level to OMP ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@n24q02m](https://github.com/n24q02m), [@abhi-wan-kenobi](https://github.com/abhi-wan-kenobi))
- Added MCP servers to OMP schedules and Hub runs ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@omercnet](https://github.com/omercnet))
- Added a Content width setting for the maximum width of chat and Markdown files on wide screens ([#5680](https://github.com/getpaseo/paseo/pull/5680))
- Added syntax highlighting for Vue files ([#6205](https://github.com/getpaseo/paseo/pull/6205) by [@Davnn1](https://github.com/Davnn1))
- Added `agents.providers.<id>.options` in `config.json` as default options for every provider, with per-agent overrides ([#5780](https://github.com/getpaseo/paseo/pull/5780))
- Added the terminal key bar and Paste to terminal panes on iPad and Android tablets ([#5712](https://github.com/getpaseo/paseo/pull/5712) by [@lardissone](https://github.com/lardissone))
- Added screens and sidebar header and footer items to the plugin SDK ([#5685](https://github.com/getpaseo/paseo/pull/5685))
### Plugins
- Changed `paseo plugin add owner/slug` to install the reviewed plugin from the plugin registry, so local directories need a path such as `./slug` and direct GitHub installs need `github:owner/repo` ([#6224](https://github.com/getpaseo/paseo/pull/6224))
- Added [screens and sidebar header and footer items](https://paseo.sh/docs/plugins/reference#screens-and-sidebar-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))
- Added a display name, icon, screenshots, and videos to the plugin manifest ([#6263](https://github.com/getpaseo/paseo/pull/6263))
- Added an `OVERVIEW.md` template to `paseo plugin init` for plugin registry listings ([#6226](https://github.com/getpaseo/paseo/pull/6226))
### Changed
- 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 details on desktop to a hover card with Refresh ([#5975](https://github.com/getpaseo/paseo/pull/5975), [#6185](https://github.com/getpaseo/paseo/pull/6185))
- Changed the context window meter to show before an agent's first turn ([#6089](https://github.com/getpaseo/paseo/pull/6089))
- Changed Codex Fast to a Speed menu listing the Normal, Fast, and Ultrafast speeds each model offers, shown as a lightning bolt on desktop that turns yellow while Fast or Ultrafast is selected ([#5708](https://github.com/getpaseo/paseo/pull/5708), [#6152](https://github.com/getpaseo/paseo/pull/6152) by [@Nurshot](https://github.com/Nurshot))
- Changed Explorer tabs to work like workspace tabs, with a + menu, drag and drop, and Close for Files and Changes ([#5942](https://github.com/getpaseo/paseo/pull/5942))
@@ -44,12 +88,18 @@
### Fixed
- Fixed Claude agents never showing Thought rows on Claude Opus 4.7 and later ([#4355](https://github.com/getpaseo/paseo/pull/4355) by [@ThePharmer](https://github.com/ThePharmer))
- Fixed a background Claude subagent's card showing as a bare "Task" that stayed running, or as "Agent" after a daemon restart ([#6067](https://github.com/getpaseo/paseo/pull/6067), [#6100](https://github.com/getpaseo/paseo/pull/6100))
- Fixed a Claude agent showing as running while only its background helpers worked, and a send or Stop killing those helpers ([#6295](https://github.com/getpaseo/paseo/pull/6295) by [@IsakPersson](https://github.com/IsakPersson))
- Fixed a Claude agent in Auto mode failing to switch modes when Claude Code uses Bedrock or Vertex ([#6272](https://github.com/getpaseo/paseo/pull/6272))
- Fixed a Claude subagent's auto-mode report showing as a raw SubagentHandback tool card instead of markdown ([#6248](https://github.com/getpaseo/paseo/pull/6248))
- Fixed background Claude subagent cards showing as a bare "Task" that stayed running, as "Agent" after a daemon restart, or unlabeled when two launch together ([#6067](https://github.com/getpaseo/paseo/pull/6067), [#6100](https://github.com/getpaseo/paseo/pull/6100), [#6268](https://github.com/getpaseo/paseo/pull/6268))
- 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 running on their default model when the configured model is one the agent does not advertise ([#6273](https://github.com/getpaseo/paseo/pull/6273))
- Fixed archiving an ACP agent leaving its provider running when the provider never answers close ([#6213](https://github.com/getpaseo/paseo/pull/6213) by [@apple-ouyang](https://github.com/apple-ouyang))
- Fixed an archived ACP agent showing an empty chat after its worktree was removed ([#6228](https://github.com/getpaseo/paseo/pull/6228))
- 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))
@@ -64,12 +114,14 @@
- 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 reopened OMP chats showing "Custom message" rows for compactions and other entries OMP writes on its own ([#6198](https://github.com/getpaseo/paseo/pull/6198), [#6215](https://github.com/getpaseo/paseo/pull/6215))
- 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 OpenCode v2 agents staying running after a question is dismissed or a tool permission is denied ([#6177](https://github.com/getpaseo/paseo/pull/6177), [#6188](https://github.com/getpaseo/paseo/pull/6188))
- 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))
@@ -83,12 +135,18 @@
- Fixed Codex terminal rows reading only "Terminal" without the command on current Codex ([#5774](https://github.com/getpaseo/paseo/pull/5774))
- Fixed the composer showing the catalog default thinking level instead of the one the agent runs on ([#5804](https://github.com/getpaseo/paseo/pull/5804))
- Fixed the composer showing another model's label when the agent's model is missing from the loaded catalog ([#5796](https://github.com/getpaseo/paseo/pull/5796))
- Fixed 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 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), [#6172](https://github.com/getpaseo/paseo/pull/6172))
- 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 copying a chat selection across an image losing its alt text and source, or including the image when the selection started below it ([#6181](https://github.com/getpaseo/paseo/pull/6181), [#6195](https://github.com/getpaseo/paseo/pull/6195))
- 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 text typed by the browser tool appearing in your message box instead of the browser tab ([#6207](https://github.com/getpaseo/paseo/pull/6207))
- Fixed Astro expressions losing their colors after a nested template string ([#6219](https://github.com/getpaseo/paseo/pull/6219))
- Fixed downloading files whose names contain non-ASCII characters ([#6278](https://github.com/getpaseo/paseo/pull/6278), [#6281](https://github.com/getpaseo/paseo/pull/6281) by [@dillonzq](https://github.com/dillonzq), [@jasonhnd](https://github.com/jasonhnd))
- Fixed the PR panel, sidebar workspace status, and "Worked for" line showing English in other app languages ([#6196](https://github.com/getpaseo/paseo/pull/6196), [#6208](https://github.com/getpaseo/paseo/pull/6208), [#6225](https://github.com/getpaseo/paseo/pull/6225) by [@dillonzq](https://github.com/dillonzq))
- Fixed French translations that read as word-for-word machine translation ([#6004](https://github.com/getpaseo/paseo/pull/6004) by [@fxlelouarn](https://github.com/fxlelouarn))
- 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))
@@ -96,12 +154,16 @@
- Fixed a new worktree workspace being archived when its `paseo.json` cannot be parsed ([#6036](https://github.com/getpaseo/paseo/pull/6036) by [@3ae3ae](https://github.com/3ae3ae))
- Fixed workspace script status not updating in other connected clients ([#6007](https://github.com/getpaseo/paseo/pull/6007) by [@swbiggart](https://github.com/swbiggart))
- Fixed agents and terminals inheriting the voice library path, which truncated Git Bash's PATH on Windows ([#5987](https://github.com/getpaseo/paseo/pull/5987) by [@hanh9898](https://github.com/hanh9898))
- Fixed dictation failing until the model was deleted by hand when the daemon stopped while extracting a local speech model ([#6210](https://github.com/getpaseo/paseo/pull/6210) by [@3ae3ae](https://github.com/3ae3ae))
- Fixed macOS hosts showing as `UNKNOWN` when the network supplies no hostname ([#5922](https://github.com/getpaseo/paseo/pull/5922))
- Fixed `paseo daemon status` reporting a daemon in a Linux VM as stopped after the host wakes from sleep ([#6279](https://github.com/getpaseo/paseo/pull/6279))
- Fixed relay reconnects being ignored after a relay connection stalled before its handshake ([#5648](https://github.com/getpaseo/paseo/pull/5648) by [@3ae3ae](https://github.com/3ae3ae))
- 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 failed send to an archived agent whose directory was removed taking it out of History ([#6255](https://github.com/getpaseo/paseo/pull/6255))
- 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))
@@ -121,6 +183,7 @@
- Fixed `paseo schedule update --no-max-runs` and `--no-expires-in` failing before reaching the daemon ([#5973](https://github.com/getpaseo/paseo/pull/5973) by [@Mar-garet](https://github.com/Mar-garet))
- Fixed `daemon.log` saying nothing when a resumed Claude agent's transcript is missing or cannot be read ([#5818](https://github.com/getpaseo/paseo/pull/5818) by [@gabrielgiordan](https://github.com/gabrielgiordan))
- Fixed reloading a plugin adding `[System Error] Provider connection closed` to finished chats on its provider ([#5579](https://github.com/getpaseo/paseo/pull/5579))
- Fixed `paseo plugin add .` looking for the plugin in the daemon's directory instead of the CLI's ([#6239](https://github.com/getpaseo/paseo/pull/6239))
- Fixed plugin settings screens on desktop having no way back to the Plugins page ([#5620](https://github.com/getpaseo/paseo/pull/5620))
- Fixed plugin client code failing to import npm packages that declare only `main` or `module` ([#5838](https://github.com/getpaseo/paseo/pull/5838))
- Fixed the SDK's `send()` and `run()` rejecting `activeTurnBehavior` in TypeScript ([#5605](https://github.com/getpaseo/paseo/pull/5605) by [@wjxdy](https://github.com/wjxdy))
+21 -9
View File
@@ -39,21 +39,33 @@
<img src="https://paseo.sh/mobile-mockup.png" alt="Paseo mobile app" width="100%">
</p>
Run agents in parallel on your own machines. Ship from your phone or your desk.
Paseo is a desktop, mobile, web, and CLI app for coding agents. Open the desktop app and work: agents, editor, terminals, diffs, pull requests, and a browser in one window. Run many agents at once, each in its own worktree, on one machine or several. The mobile app is the full app, native on iOS and Android.
- **Parallel agents:** Run many agents at once, each in its own worktree.
- **Built-in orchestration:** Agents in Paseo can create worktrees, launch other agents, and talk to them, across providers.
- **Full IDE:** Edit files, review diffs, open pull requests, and run terminals, in split panes you arrange how you want.
- **Self-hosted:** Agents run on your machine with your full dev environment. Use your tools, your configs, and your skills.
- **Multi-provider:** Claude Code, Codex, Copilot, OpenCode, Pi, Antigravity, and Muse Code through the same interface. Pick the right model for each job.
- **Voice control:** Dictate tasks or talk through problems in voice mode. Hands-free when you need it.
- **Cross-device:** iOS, Android, desktop, web, and CLI. Start work at your desk, check in from your phone, script it from the terminal.
- **Privacy-first:** Paseo doesn't have any telemetry, tracking, or forced log-ins.
[Run parallel tasks in Paseo](https://paseo.sh/docs/parallel-development): start agents in separate worktrees, review their diffs, run each app, and check it in the built-in browser.
## Plugins
Add themes, workspace panels, commands, settings screens, and coding-agent providers with trusted
TypeScript plugins. Install from npm, Git, or a local directory with `paseo plugin install <source>`.
Plugins run on the daemon and show up in every client you connect, with the same UI on desktop, web,
iOS, and Android. Write a plugin once and it is on your phone.
Start with the [plugin quickstart](https://paseo.sh/docs/plugins). Plugins run with access to your daemon
machine and inside connected clients; install only code you trust.
- **UI:** screens, sidebar items, workspace panels, Command Center items, slash commands, composer pills, attachment sources, timeline items, themes.
- **Agent lifecycle:** change configuration, environment, and MCP servers, answer permissions, follow up when a turn ends.
- **Providers:** add a coding agent as a provider.
Install from the registry with `paseo plugin add owner/slug`, or from Git or a local directory.
**[Browse plugins](https://paseo.sh/plugins)** · **[Plugin docs](https://paseo.sh/docs/plugins)**
Plugins run with access to your daemon machine and inside connected clients; install only code you trust.
## Getting Started
@@ -77,16 +89,16 @@ Download it from [paseo.sh/download](https://paseo.sh/download) or the [GitHub r
To connect from your phone, open **Settings → your host → Pair Device**.
### CLI / headless
### Server
Install the CLI and start Paseo:
For a server, a VM, or any machine without the desktop app. Install the CLI and start the daemon:
```bash
npm install -g @getpaseo/cli
paseo
```
Paseo starts locally, then asks whether to enable the end-to-end encrypted relay for device pairing. If you decline, connect directly over TCP, Tailscale, or another VPN. This path is useful for servers and remote machines.
Paseo starts, then asks whether to enable the end-to-end encrypted relay for device pairing. If you decline, connect directly over TCP, Tailscale, or another VPN. The desktop, mobile, and web apps connect to this daemon like any other host.
For full setup and configuration, see:
@@ -197,7 +209,7 @@ npm run typecheck
## Sponsors
Paseo is built by one person and funded by the people who use it. Support the work on [GitHub Sponsors](https://github.com/sponsors/boudra). Companies can [sponsor Paseo](https://paseo.sh/sponsor#spot) monthly and have their logo shown here and on the paseo.sh homepage.
Paseo is an independent project used by tens of thousands of developers daily, built by one person and funded by the people who use it. Support the work on [GitHub Sponsors](https://github.com/sponsors/boudra). Companies can [sponsor Paseo](https://paseo.sh/sponsor#spot) monthly and have their logo shown here and on the paseo.sh homepage.
<!-- Sponsor logos go here, in the same order as packages/website/src/data/sponsors.ts -->
+2 -1
View File
@@ -197,10 +197,11 @@ Claude Code announces subagent lifecycle on the SDK stream (`task_started` / `ta
- **Not every announced task belongs in the track.** Task subagents announce as `local_agent` and workflows as `local_workflow`; a backgrounded shell announces as `local_bash` with the same `tool_use_id` shape, and ambient housekeeping sets `skip_transcript`. The Claude provider normalizes a workflow to a generic provider-subagent descriptor titled `Workflow`, using Claude's summary as its description and timeline opener. Shared storage, protocol, and UI do not distinguish it from another provider subagent.
- **A task that was never declared gets no descriptor, by any route.** Filtered tasks still emit `task_notification`s carrying a `tool_use_id`, and still emit frames carrying `parent_tool_use_id`. Attributing either produces a descriptor with no identity and a defaulted `running` status — a nameless row that never finishes. Status, presentation updates, and sidechain frames all route through the declaration table.
- **Task ids are session-scoped, not turn-scoped.** Cancelling a turn must not clear the routing table: a backgrounded child settles after the interrupt and needs its descriptor to still exist. Cancellation instead terminalizes the declared children that were running in the foreground, and a later `task_notification` is free to correct that guess. Backgrounded children are identified by `task_updated.patch.is_backgrounded`.
- **Task ids are session-scoped, not turn-scoped.** Cancelling a turn must not clear the routing table: a backgrounded child settles after the interrupt and needs its descriptor to still exist. Cancellation instead terminalizes the declared children that were running in the foreground, and a later `task_notification` is free to correct that guess. Backgrounded children are identified by `is_backgrounded` on `task_started` (set at spawn; a resumed subagent is always background) or, when moved there later, on `task_updated.patch`.
- **A resumed task can be announced again with a new `tool_use_id`.** The first Task tool id remains the canonical descriptor and later ids are routing aliases for the same session-scoped task. The resumed prompt is added to that child timeline.
- **Effort is only reachable through hooks.** It appears nowhere on the message stream at any depth, and the level Paseo requests is not necessarily the level that runs — a model that does not support it is silently downgraded. A hook firing inside a subagent reports the active post-downgrade level next to its `agent_id`, which is the same id `task_started` calls `task_id`.
- **Backgrounded subagents emit no frames carrying `parent_tool_use_id` at all.** Everything keyed off that field sees nothing for one; they are visible only because the task protocol announces them.
- **Only `system/init` and `result` bracket a main-session turn.** Background subagents emit neither on the main stream, while their task frames, their own background jobs' `task_notification`, and `rate_limit_event` arrive with no `parent_tool_use_id`, the same shape as main-session work. `session_state_changed` stays `running` for as long as any background subagent lives (Claude Code 2.1.280), so it cannot open a turn; its `idle` can close one.
- **Nested ownership comes from the launching sidechain, not `spawn_depth` alone.** A sidechain's Agent or Bash tool call records the direct owner of that `tool_use_id`; the following `task_started` inherits it. This routes a grandchild descriptor and child-owned background notifications without relying on labels or flattening them into the managed parent.
- **On replay, `<session>/subagents/` holds every descendant beside the root.** Resolve the tree one proven generation at a time: the root transcript admits direct children, then each admitted sidechain transcript admits its children by `toolUseId`. `spawnDepth` orders candidates but does not establish ownership. Unresolved sidecars remain excluded as ambient or unrelated work.
- **Replay `totalTokens` is a context-size reading, not cumulative spend.** Claude Code finalizes a subagent by summing the _last_ assistant message's usage block and shipping that as `usage.total_tokens`. Summing per-entry usage instead multiplies the cached prefix by the turn count and reports a number several times larger than the live path.
+1 -1
View File
@@ -484,7 +484,7 @@ The [Agent Client Protocol (ACP)](https://agentclientprotocol.com) is an open st
ACP agents communicate over JSON-RPC 2.0 on stdio. Paseo spawns the agent process and talks to it through stdin/stdout.
Paseo also ships an in-app ACP provider catalog for common agents, including CodeWhale, Cursor, DeepAgents, DimCode, Gemini CLI, Hermes, Qwen Code, and Kimi Code. Catalog entries create the same `extends: "acp"` provider config shown below.
Paseo also ships an in-app ACP provider catalog for common agents, including CodeWhale, Cursor, DeepAgents, DimCode, Gemini CLI, Hermes, Qwen Code, and Kimi Code. Most catalog entries create the same `extends: "acp"` provider config shown below. Hoplite activates its [bundled provider](plugins.md#built-in-plugins) instead.
### Adding a generic ACP provider
+23 -12
View File
@@ -107,6 +107,12 @@ absent from the installed plugin list and source configuration; their client bun
appear in the plugin catalog. Editing one in development requires a daemon restart.
Directory, Git, and npm installs cannot use a built-in ID.
A provider registration with `requiresConfiguration: true` participates only while its ID exists
under `agents.providers`. Hoplite uses this for catalog opt-in. Add writes `{ "enabled": true }`
without `extends`, so it selects the bundled adapter. Remove deletes the config entry; setting
`enabled: false` keeps the entry. The bundled JavaScript still loads at startup, but an absent
provider does not enter executable resolution or catalog discovery.
Provider plugins use separate installation and provider IDs. `muse-provider` registers the
selectable `muse` provider (Muse Code) and a usage source. Its `status({ launch })` reports
availability and a diagnostic after the daemon resolves the executable. Configure command,
@@ -117,15 +123,19 @@ the bundled integration; an entry with `extends` shadows it with a custom provid
## Install from a registry
Registry installs are off by default. Set `pluginRegistryEnabled: true` in daemon config or
`PASEO_PLUGIN_REGISTRY_ENABLED=1`, then restart the daemon. While they are off, bare
`owner/repo` is GitHub shorthand and the daemon never contacts a registry.
With registry installs on, `paseo plugin install owner/slug` installs the registry's reviewed
artifact, and GitHub shorthand requires `github:`. Registry installs
`paseo plugin add owner/slug` installs the registry's reviewed artifact by default.
Browse published plugins at [paseo.sh/plugins](https://paseo.sh/plugins).
Use `git:owner/repository` or a full Git URL for a Git source. Registry installs
keep the registry URL and ID, so update checks use its approved pin. Explicit version/ref
selection is unavailable for registry installs; install an explicit source to select your own.
Registry overviews are untrusted author content, even before installation. Render them with
`@getpaseo/protocol/plugin-overview`: no raw HTML, HTTPS-only links and images, no relative
URLs, and isolated external links. The website adapter and corpus tests live in
`packages/website/src/plugins/overview.tsx` and `overview.test.tsx`; the reusable corpus is
`packages/protocol/tests/fixtures/plugin-overview.json`. A future app overview must share
this policy. Installed-plugin descriptions in the app remain plain text.
Use `host/owner/slug` for a registry at `https://host`, or set
`PASEO_PLUGIN_REGISTRY` to change the default base (including a path prefix).
Private registry credentials live in daemon config under
@@ -138,13 +148,13 @@ owns static hosting, record shapes, pins, and advisory install counts.
## Install a Git source
GitHub repositories use `owner/repository` or `github:owner/repository`. Other hosts use a Git
URL. An existing directory still wins over source resolution.
GitHub repositories use `git:owner/repository` or `github:owner/repository`. Other hosts use a Git
URL.
```bash
paseo plugin install owner/repository
paseo plugin install git:owner/repository
paseo plugin install https://gitlab.com/group/repository.git
paseo plugin install owner/monorepo:plugins/review
paseo plugin install git:owner/monorepo:plugins/review
paseo plugin install github:owner/repository --ref main
```
@@ -155,8 +165,9 @@ and ask for approval. `ls` reports the installed commit without contacting the r
## Managed source ownership
The [public source reference](../public-docs/plugins/reference.md#plugin-sources) owns identifier
syntax and npm prerequisites; the [publishing guide](../public-docs/plugins/publishing.md) owns distribution. Both clients send the source unchanged
through `installPluginSource`; only the daemon resolves host paths and acquires sources.
syntax and npm prerequisites; the [publishing guide](../public-docs/plugins/publishing.md) owns distribution. The CLI resolves directory sources before
calling `installPluginSource`; see the public source reference for CLI and app path semantics.
Only the daemon reads plugin files and acquires sources.
`ManagedPluginSources` owns acquisition and offline source description. Config stores the active
directory; sources.json stores managed kind and the Git acquisition remote. The remote is needed
+1 -1
View File
@@ -103,7 +103,7 @@ OpenCode owns user message IDs. Do not pass Paseo-generated IDs to OpenCode prom
Active-turn steering is an optional `AgentSession.steerActiveTurn` operation. The manager owns admission against its exact foreground turn, canonical user-message creation, echo reconciliation, and falls back to the normal interrupt-and-replace path only when the adapter reports `unavailable`. An adapter error leaves the steer's fate ambiguous and must surface without an interrupt or retry. Codex calls `turn/steer` with the native expected turn and Paseo client user-message ID. Claude pushes an admitted steer into the exact active SDK query input; isolated control commands remain unavailable. OpenCode calls `session/prompt_async` with an OpenCode-generated message ID; the server queues the prompt while busy and the next LLM call in the same Paseo turn includes it. Pi sends its native `steer` RPC, which queues the message for delivery after the in-flight assistant turn's tool calls. Slash-command inputs report `unavailable` because pi rejects extension commands on the steer path, and echo identity is correlated by message text because pi's steer RPC takes no message ID. A missing session reports `unavailable` and uses the normal interrupt fallback.
A steering adapter also owes its interrupt: stopping a turn must discard the steers the provider has not read yet, or one of them resumes the turn the user just stopped. Codex clears pending input when it aborts a turn; Claude does not, so its adapter cancels the SDK messages it queued before calling `query.interrupt()`. Pi requires `clear_queue` before `abort`; older binaries without that RPC retain their native queue behavior until the pi compatibility floor reaches 0.84.4.
A steering adapter also owes its interrupt: stopping a turn must discard the steers the provider has not read yet, or one of them resumes the turn the user just stopped. Codex clears pending input when it aborts a turn; Claude does not, so its adapter cancels the SDK messages it queued before calling `query.interrupt()`. It calls `query.interrupt()` only while a main-session turn is in flight (between `system/init` and that turn's `result`): with the main session idle, an interrupt reaches only background subagents and no result follows it, so the adapter withdraws its unstarted messages and stops there. The adapter also declares `perTaskStopAffordance`, because without it the CLI kills every background subagent on interrupt. Pi requires `clear_queue` before `abort`; older binaries without that RPC retain their native queue behavior until the pi compatibility floor reaches 0.84.4.
`SteerActiveTurnOptions.clearPendingPermissions` makes permission release part of the provider contract. A provider that accepts such a steer queues it first, denies permissions blocking its delivery, and stops once the steer is read. Steers without the flag leave permissions open. A denied plan remains in the timeline because the pending card was the only other copy of its text.
@@ -0,0 +1,7 @@
Added
- Added Muse Code as a provider, with model, approval mode, and reasoning effort controls
- Added Antigravity as a provider that drives the installed agy CLI, always in Full access
- Added Usage, opened from the sidebar footer as a dialog on desktop and a sheet on mobile, showing each subscription account's quota windows with Refresh
- Added an opt-in Usage summary to the sidebar footer that shows the windows you pin
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,7 @@
Added
- Added Muse Code as a provider, with model, approval mode, and reasoning effort controls
- Added Antigravity as a provider that drives the installed agy CLI, always in Full access
- Added Usage, opened from the sidebar footer as a dialog on desktop and a sheet on mobile, showing each subscription account's quota windows with Refresh
- Added an opt-in Usage summary to the sidebar footer that shows the windows you pin
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,7 @@
Added
- Added Muse Code as a provider, with model, approval mode, and reasoning effort controls
- Added Antigravity as a provider that drives the installed agy CLI, always in Full access
- Added Usage, opened from the sidebar footer as a dialog on desktop and a sheet on mobile, showing each subscription account's quota windows with Refresh
- Added an opt-in Usage summary to the sidebar footer that shows the windows you pin
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,7 @@
Added
- Added Muse Code as a provider, with model, approval mode, and reasoning effort controls
- Added Antigravity as a provider that drives the installed agy CLI, always in Full access
- Added Usage, opened from the sidebar footer as a dialog on desktop and a sheet on mobile, showing each subscription account's quota windows with Refresh
- Added an opt-in Usage summary to the sidebar footer that shows the windows you pin
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,8 @@
Added
- Added Claude Haiku 5.5 for Claude Code 2.1.293 and newer
Fixed
- Fixed Claude tool calls that run longer than 30 seconds, such as WebFetch or MCP tools, showing up as subagents
- Fixed subagents that finish while the app is disconnected staying "working" until one is opened
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,8 @@
Added
- Added Claude Haiku 5.5 for Claude Code 2.1.293 and newer
Fixed
- Fixed Claude tool calls that run longer than 30 seconds, such as WebFetch or MCP tools, showing up as subagents
- Fixed subagents that finish while the app is disconnected staying "working" until one is opened
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,8 @@
Added
- Added Claude Haiku 5.5 for Claude Code 2.1.293 and newer
Fixed
- Fixed Claude tool calls that run longer than 30 seconds, such as WebFetch or MCP tools, showing up as subagents
- Fixed subagents that finish while the app is disconnected staying "working" until one is opened
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,8 @@
Added
- Added Claude Haiku 5.5 for Claude Code 2.1.293 and newer
Fixed
- Fixed Claude tool calls that run longer than 30 seconds, such as WebFetch or MCP tools, showing up as subagents
- Fixed subagents that finish while the app is disconnected staying "working" until one is opened
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,4 @@
Fixed
- Fixed intermittent text clipping in iOS chats and Markdown previews
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,4 @@
Fixed
- Fixed intermittent text clipping in iOS chats and Markdown previews
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,4 @@
Fixed
- Fixed intermittent text clipping in iOS chats and Markdown previews
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,4 @@
Fixed
- Fixed intermittent text clipping in iOS chats and Markdown previews
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,11 @@
Added
- Added Hoplite as an opt-in provider in the ACP provider catalog
- Added search to the Add provider catalog
Changed
- Changed Add provider failures to show as a toast
Fixed
- Fixed Remove being offered for plugin providers such as Antigravity and Muse Code
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,11 @@
Added
- Added Hoplite as an opt-in provider in the ACP provider catalog
- Added search to the Add provider catalog
Changed
- Changed Add provider failures to show as a toast
Fixed
- Fixed Remove being offered for plugin providers such as Antigravity and Muse Code
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,11 @@
Added
- Added Hoplite as an opt-in provider in the ACP provider catalog
- Added search to the Add provider catalog
Changed
- Changed Add provider failures to show as a toast
Fixed
- Fixed Remove being offered for plugin providers such as Antigravity and Muse Code
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,11 @@
Added
- Added Hoplite as an opt-in provider in the ACP provider catalog
- Added search to the Add provider catalog
Changed
- Changed Add provider failures to show as a toast
Fixed
- Fixed Remove being offered for plugin providers such as Antigravity and Muse Code
Full notes: https://paseo.sh/changelog
+1 -1
View File
@@ -1 +1 @@
sha256-mQOyGIvwVt45hldUnYuZTKJg4mLPm7d8jXXNlSQnExE=
sha256-AKY6OMZgHeyn/To/2zsmtfb5C4fKniUp8gvpEYaR7/o=
+29 -28
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.11.0-beta.5",
"version": "0.11.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.11.0-beta.5",
"version": "0.11.3",
"hasInstallScript": true,
"license": "Apache-2.0",
"workspaces": [
@@ -36413,7 +36413,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.11.0-beta.5",
"version": "0.11.3",
"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.5",
"version": "0.11.3",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.11.0-beta.5",
"@getpaseo/protocol": "0.11.0-beta.5",
"@getpaseo/server": "0.11.0-beta.5",
"@getpaseo/client": "0.11.3",
"@getpaseo/protocol": "0.11.3",
"@getpaseo/server": "0.11.3",
"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.5",
"version": "0.11.3",
"dependencies": {
"@getpaseo/protocol": "0.11.0-beta.5",
"@getpaseo/relay": "0.11.0-beta.5",
"@getpaseo/protocol": "0.11.3",
"@getpaseo/relay": "0.11.3",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -37762,7 +37762,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.11.0-beta.5",
"version": "0.11.3",
"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.5",
"version": "0.11.3",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -38902,7 +38902,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.11.0-beta.5",
"version": "0.11.3",
"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.5",
"version": "0.11.3",
"dependencies": {
"@agentclientprotocol/sdk": "^1.4.0",
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.11.0-beta.5",
"@getpaseo/protocol": "0.11.0-beta.5",
"@getpaseo/client": "0.11.3",
"@getpaseo/protocol": "0.11.3",
"@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.5",
"@getpaseo/protocol": "0.11.0-beta.5",
"@getpaseo/client": "0.11.3",
"@getpaseo/protocol": "0.11.3",
"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.5",
"version": "0.11.3",
"dependencies": {
"semver": "^7.8.5",
"zod": "^4.4.3"
@@ -38998,7 +38998,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.11.0-beta.5",
"version": "0.11.3",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -39216,16 +39216,16 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.11.0-beta.5",
"version": "0.11.3",
"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.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",
"@getpaseo/client": "0.11.3",
"@getpaseo/highlight": "0.11.3",
"@getpaseo/plugin": "0.11.3",
"@getpaseo/protocol": "0.11.3",
"@getpaseo/relay": "0.11.3",
"@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.5",
"version": "0.11.3",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
@@ -40174,6 +40174,7 @@
"@types/react": "^19.1.6",
"@types/react-dom": "^19.1.5",
"@vitejs/plugin-react": "^4.5.1",
"parse5": "^7.3.0",
"typescript": "^5.9.3",
"vite": "^7.3.3",
"vite-tsconfig-paths": "^5.1.4",
@@ -41753,7 +41754,7 @@
},
"plugins": {
"name": "@getpaseo/builtin-plugins",
"version": "0.11.0-beta.5",
"version": "0.11.3",
"dependencies": {
"@getpaseo/plugin": "*",
"zod": "^4.4.3"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.11.0-beta.5",
"version": "0.11.3",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
@@ -1,13 +1,24 @@
import { test } from "../support/fixtures";
import {
closeModelPicker,
openModelPicker,
seedAgentProfiles,
} from "../support/helpers/agent-profiles";
import { expectComposerVisible } from "../support/helpers/composer";
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
import { seedWorkspace } from "../support/helpers/seed-client";
import { writeFile } from "node:fs/promises";
import { expect, test } from "../support/fixtures";
import { gotoAppShell, openSettings } from "../support/helpers/app";
import { connectDaemonClient } from "../support/helpers/daemon-client-loader";
import { getServerId } from "../support/helpers/server-id";
import {
expectProviderInstalledInSettings,
findAcpCatalogProvider,
installAcpCatalogProvider,
openAddProviderArea,
openSettingsHost,
openSettingsHostSection,
seedSavedSettingsHosts,
} from "../support/helpers/settings";
const ACP_PROVIDER = {
@@ -16,8 +27,13 @@ const ACP_PROVIDER = {
};
interface ProviderCatalogDaemonClient {
getLastServerInfoMessage(): {
features?: { hopliteProvider?: boolean; plugins?: boolean };
} | null;
connect(): Promise<void>;
close(): Promise<void>;
getDaemonConfig(): Promise<{ config: { providers?: Record<string, unknown> } }>;
getProvidersSnapshot(): Promise<{ entries: Array<{ provider: string }> }>;
patchDaemonConfig(config: { removeProviders?: string[] }): Promise<unknown>;
}
@@ -41,3 +57,124 @@ test.describe("ACP provider catalog", () => {
}
});
});
for (const theme of ["light", "dark"] as const) {
test(`adds Hoplite from the catalog in ${theme} theme`, async ({ page }, testInfo) => {
const client = await connectDaemonClient<ProviderCatalogDaemonClient>({
clientIdPrefix: "hoplite-catalog-e2e",
});
try {
expect(client.getLastServerInfoMessage()?.features?.hopliteProvider).toBe(true);
await client.patchDaemonConfig({ removeProviders: ["hoplite"] });
expect(
(await client.getProvidersSnapshot()).entries.some((entry) => entry.provider === "hoplite"),
).toBe(false);
await page.addInitScript((value) => {
localStorage.setItem("@paseo:app-settings", JSON.stringify({ theme: value }));
}, theme);
await gotoAppShell(page);
await openSettings(page);
await openSettingsHost(page, getServerId());
await openSettingsHostSection(page, getServerId(), "providers");
await expect(
page.getByRole("button", { name: "Hoplite provider details", exact: true }),
).toHaveCount(0);
await openAddProviderArea(page);
await findAcpCatalogProvider(page, "Hoplite");
await page.screenshot({ path: testInfo.outputPath(`hoplite-catalog-${theme}.png`) });
await installAcpCatalogProvider(page, "Hoplite");
await expectProviderInstalledInSettings(page, "Hoplite");
await page.screenshot({ path: testInfo.outputPath(`hoplite-installed-${theme}.png`) });
expect((await client.getDaemonConfig()).config.providers?.hoplite).toEqual({ enabled: true });
await writeFile(
testInfo.outputPath(`hoplite-added-${theme}.json`),
JSON.stringify(await client.getProvidersSnapshot(), null, 2),
);
await removeHoplite(page);
await expect(
page.getByRole("button", { name: "Hoplite provider details", exact: true }),
).toHaveCount(0);
expect((await client.getDaemonConfig()).config.providers?.hoplite).toBeUndefined();
expect(
(await client.getProvidersSnapshot()).entries.some((entry) => entry.provider === "hoplite"),
).toBe(false);
await writeFile(
testInfo.outputPath(`hoplite-removed-${theme}.json`),
JSON.stringify(await client.getProvidersSnapshot(), null, 2),
);
await openAddProviderArea(page);
await findAcpCatalogProvider(page, "Hoplite");
await page.screenshot({ path: testInfo.outputPath(`hoplite-removed-${theme}.png`) });
await installAcpCatalogProvider(page, "Hoplite");
await expectProviderInstalledInSettings(page, "Hoplite");
} finally {
await client.patchDaemonConfig({ removeProviders: ["hoplite"] }).catch(() => undefined);
await client.close().catch(() => undefined);
}
});
}
async function removeHoplite(page: import("@playwright/test").Page): Promise<void> {
await page.getByTestId("provider-actions-hoplite").click();
page.once("dialog", (dialog) => {
void dialog.accept();
});
await page.getByTestId("provider-remove-hoplite").click();
}
test("unsupported host asks for an update without writing Hoplite config", async ({
page,
outdatedDaemon,
}, testInfo) => {
const client = await connectDaemonClient<ProviderCatalogDaemonClient>({
clientIdPrefix: "hoplite-unsupported",
port: Number(new URL(`http://${outdatedDaemon.endpoint}`).port),
});
try {
expect(client.getLastServerInfoMessage()?.features?.plugins).toBe(true);
expect(client.getLastServerInfoMessage()?.features?.hopliteProvider).toBe(false);
const before = await client.getDaemonConfig();
await seedSavedSettingsHosts(page, [outdatedDaemon]);
await page.reload();
await openSettings(page);
await openSettingsHost(page, outdatedDaemon.serverId);
await openSettingsHostSection(page, outdatedDaemon.serverId, "providers");
await openAddProviderArea(page);
await page.getByRole("button", { name: "Add Hoplite", exact: true }).click();
const errorToast = page.getByTestId("app-toast-message");
await expect(errorToast).toHaveText("Update the host to add Hoplite.");
await expect(errorToast).toBeInViewport();
expect((await client.getDaemonConfig()).config).toEqual(before.config);
await expect(
page.getByRole("button", { name: "Hoplite provider details", exact: true }),
).toHaveCount(0);
await page.screenshot({ path: testInfo.outputPath("hoplite-update-host.png") });
} finally {
await client.close();
}
});
test("model selector excludes Hoplite before opt-in", async ({ page }, testInfo) => {
const client = await connectDaemonClient<ProviderCatalogDaemonClient>({
clientIdPrefix: "hoplite-picker",
});
const profiles = await seedAgentProfiles([
{ id: "hoplite-picker-anchor", name: "Picker anchor", provider: "mock" },
]);
const workspace = await seedWorkspace({ repoPrefix: "hoplite-picker-" });
try {
await client.patchDaemonConfig({ removeProviders: ["hoplite"] });
await gotoWorkspace(page, workspace.workspaceId);
await clickNewChat(page);
await expectComposerVisible(page);
await openModelPicker(page);
await expect(page.getByTestId("model-provider-mock")).toBeVisible();
await expect(page.getByTestId("model-provider-hoplite")).toHaveCount(0);
await page.screenshot({ path: testInfo.outputPath("hoplite-model-selector-absent.png") });
await closeModelPicker(page);
} finally {
await workspace.cleanup();
await profiles.restore();
await client.close();
}
});
@@ -133,6 +133,58 @@ test("scroll detector accounts for delayed wheel input and growth below the new
).toHaveLength(1);
});
test("scroll detector follows the entered image through resize and virtual offset correction", () => {
for (const height of [225, 1632]) {
const before: ScrollFrame = {
at: 1000,
scrollTop: 2208,
scrollHeight: 5000,
viewportHeight: 516,
virtualized: true,
loading: false,
rows: [
{ id: "image", top: -704, height: 560 },
{ id: "caption", top: -144, height: 100 },
{ id: "old-reader", top: -44, height: 127 },
],
anchor: "old-reader",
wheelTotal: 0,
lastWheelAt: 0,
inputFinishedAt: null,
imageLoads: 0,
mounted: 3,
unmounted: 0,
};
const resize = height - 560;
const after: ScrollFrame = {
...before,
at: 1060,
// The virtualizer corrects 110px of estimates while the wheel moves 160px.
scrollTop: 1938,
scrollHeight: 5000 + resize - 110,
wheelTotal: 160,
lastWheelAt: 1020,
imageLoads: 1,
rows: [
{ id: "image", top: -544, height },
{ id: "caption", top: 16 + resize, height: 100 },
{ id: "old-reader", top: 116 + resize, height: 127 },
],
};
expect(findScrollJumps([before, after])).toEqual([]);
// Resizing must not excuse additional viewport movement.
expect(
findScrollJumps([
before,
{
...after,
rows: after.rows.map((row) => ({ id: row.id, height: row.height, top: row.top + 100 })),
},
]),
).toHaveLength(1);
}
});
for (const cadence of scrollCadences) {
test(`varied timeline preserves reading position during ${cadence.name} upward scrolling`, async ({
page,
@@ -1,3 +1,4 @@
import overviewCorpus from "../../../protocol/tests/fixtures/plugin-overview.json";
import { pluginRequirements } from "../support/helpers/plugin-fixture";
import { execFileSync } from "node:child_process";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
@@ -557,7 +558,7 @@ async function retryGitInstallAndInspectRows(
await installPlugin(page, missingSource);
await expect(page.getByTestId("plugin-management-feedback")).toContainText(
"Plugin source is neither an existing directory nor a Git URL",
`Plugin directory does not exist: ${missingSource}`,
);
await expect(page.getByLabel("Plugin source")).toHaveValue(missingSource);
await capturePluginInstallForm(page, testInfo, "compact-error");
@@ -655,3 +656,27 @@ async function reloadAndRemoveNpmPlugin(page: Page) {
await expect(page.getByText("Removed npm-review", { exact: true })).toBeVisible();
await expect(page.getByLabel("npm-review running")).toHaveCount(0);
}
test("renders installed-plugin descriptions as plain text", async ({ page, pluginEnvironment }) => {
const description = overviewCorpus.map(({ markdown }) => markdown).join("\n\n");
await installPluginWithUntrustedDescription(pluginEnvironment, description);
await gotoAppShell(page);
await openPluginSettings(page);
const text = page.getByText(description, { exact: true });
await expect(text).toBeVisible();
// This field must remain a text node, with no Markdown links, images, or HTML children.
expect(await text.evaluate((element) => element.childElementCount)).toBe(0);
});
async function installPluginWithUntrustedDescription(
environment: PluginEnvironment,
description: string,
) {
const directory = await createDirectoryPlugin(
environment.directory,
"plain-text-plugin",
description,
"Plain text plugin",
);
await environment.client.installPluginSource({ source: directory });
}
@@ -10,16 +10,29 @@ import {
openSettingsHost,
openSettingsHostSection,
} from "../support/helpers/settings";
import { copyPluginExample } from "../support/helpers/plugin-fixture";
const CUSTOM_PROVIDER = {
id: "junie",
name: "Junie",
} as const;
const PLUGIN_PROVIDER = {
id: "direct-example",
name: "Direct provider example",
pluginId: "provider-direct-example",
} as const;
interface ProviderRemovalDaemonClient {
connect(): Promise<void>;
close(): Promise<void>;
patchDaemonConfig(config: { removeProviders?: string[] }): Promise<unknown>;
getDaemonConfig(): Promise<{ config: { pluginsEnabled?: boolean } }>;
patchDaemonConfig(config: {
removeProviders?: string[];
pluginsEnabled?: boolean;
}): Promise<unknown>;
installDirectoryPlugin(path: string): Promise<unknown>;
removePlugin(pluginId: string): Promise<void>;
getProvidersSnapshot(): Promise<{
entries: Array<{ provider: string; source?: "builtin" | "custom" }>;
}>;
@@ -93,4 +106,45 @@ test.describe("provider removal", () => {
await client.close().catch(() => undefined);
}
});
test("does not offer Remove for a plugin-registered provider", async ({ page }) => {
test.setTimeout(120_000);
const client = await connectDaemonClient<ProviderRemovalDaemonClient>({
clientIdPrefix: "provider-removal-e2e",
});
const plugin = await copyPluginExample("provider-direct");
try {
const previous = await client.getDaemonConfig();
try {
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(plugin.directory);
await gotoAppShell(page);
await openSettings(page);
await openSettingsHost(page, getServerId());
await openSettingsHostSection(page, getServerId(), "providers");
const pluginProviderRow = page.getByRole("button", {
name: `${PLUGIN_PROVIDER.name} provider details`,
exact: true,
});
await expect(pluginProviderRow).toBeVisible();
await expect(page.getByTestId(`provider-actions-${PLUGIN_PROVIDER.id}`)).toHaveCount(0);
// The provider survives a removeProviders patch pointed at it: the
// plugin, not config.json, owns the definition.
await client.patchDaemonConfig({ removeProviders: [PLUGIN_PROVIDER.id] });
await expect(pluginProviderRow).toBeVisible();
} finally {
await client.removePlugin(PLUGIN_PROVIDER.pluginId).catch(() => undefined);
await client
.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled })
.catch(() => undefined);
}
} finally {
await client.close().catch(() => undefined);
await plugin.cleanup();
}
});
});
@@ -1,26 +0,0 @@
# Native iOS text geometry regression
Run on the Namespace Mac with a fresh simulator development build. Use the PID
returned by `xcrun simctl launch <your-task-simulator-UDID> sh.paseo.debug`.
Attach only to your task's app, while no device replay is running:
```sh
PASEO_NATIVE_TEXT_REFLOW_PID=12345 \
PASEO_NATIVE_TEXT_REFLOW_LOG=/Volumes/devbox/workspace/native-text-reflow.log \
bash packages/app/e2e/mobile/native-text-reflow/ios.sh
```
The probe constructs the installed `RNUITextView`, fills its real UIKit text view,
and resizes its content container from 500 to 240 and back to 500 points. It
asserts the inner frame follows both widths and the same selection survives.
It uses public UIView/UITextView operations, without mocking React Native.
LLDB detaches after the probe. An assertion failure or evaluation error fails the
shell command; the log distinguishes them.
This covers native container resizing and selection retention. It does not cover
Fabric event delivery, chat row height, reader anchoring, or the on-screen copy
menu. Those require the real chat journey: read an older multiline response,
open/close Explorer, resize/rotate, inspect complete paragraphs and bullets,
retain expanded/collapsed details, and check a stationary reader's position.
Accessibility exposes full text even when glyphs are clipped, so a text wait
alone does not verify that journey.
@@ -1,55 +0,0 @@
import lldb
def __lldb_init_module(debugger, _internal_dict):
debugger.HandleCommand("expression -l objc++ -- @import UIKit")
frame = debugger.GetSelectedTarget().GetProcess().GetSelectedThread().GetSelectedFrame()
options = lldb.SBExpressionOptions()
options.SetLanguage(lldb.eLanguageTypeObjC_plus_plus)
result = frame.EvaluateExpression(
r'''({
UIView *host = (UIView *)[[NSClassFromString(@"RNUITextView") alloc] init];
auto hostFrame = host.frame;
hostFrame.size.width = 500;
hostFrame.size.height = 200;
host.frame = hostFrame;
UIView *container = (UIView *)[host performSelector:@selector(contentView)];
container.frame = host.bounds;
UITextView *text = nil;
for (UIView *child in host.subviews) {
if ([child isKindOfClass:UITextView.class]) text = (UITextView *)child;
for (UIView *nested in child.subviews) {
if ([nested isKindOfClass:UITextView.class]) text = (UITextView *)nested;
}
}
text.frame = container.bounds;
text.font = [UIFont systemFontOfSize:16];
text.text = @"A realistic multiline paragraph must reflow when Explorer narrows the available chat width, then fill the restored width without replacing the text view or discarding a selection.";
auto range = text.selectedRange;
range.location = 12;
range.length = 9;
text.selectedRange = range;
auto frame = container.frame;
frame.size.width = 240;
container.frame = frame;
[container layoutIfNeeded];
CGFloat narrow = text.frame.size.width;
auto selection = text.selectedRange;
frame.size.width = 500;
container.frame = frame;
[container layoutIfNeeded];
CGFloat wide = text.frame.size.width;
BOOL ok = text != nil && narrow == 240 && wide == 500 &&
selection.location == text.selectedRange.location &&
selection.length == text.selectedRange.length &&
selection.location == 12 && selection.length == 9;
[NSString stringWithFormat:@"NATIVE_REFLOW_%@ narrow=%.0f wide=%.0f selection=%lu:%lu",
ok ? @"PASS" : @"FAIL", narrow, wide,
(unsigned long)text.selectedRange.location,
(unsigned long)text.selectedRange.length];
})''',
options,
)
if result.GetError().Fail():
raise RuntimeError(result.GetError().GetCString())
print(result.GetObjectDescription())
@@ -1,18 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
: "${PASEO_NATIVE_TEXT_REFLOW_PID:?Set the PID of your task-owned Paseo iOS simulator app}"
: "${PASEO_NATIVE_TEXT_REFLOW_LOG:?Set the test output log path}"
if [[ "$(ps -p "$PASEO_NATIVE_TEXT_REFLOW_PID" -o comm=)" != *PaseoDebug.app/PaseoDebug ]]; then
echo "Expected a running PaseoDebug simulator app" >&2
exit 1
fi
probe_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
xcrun lldb --batch -p "$PASEO_NATIVE_TEXT_REFLOW_PID" \
-o "command script import $probe_dir/ios.py" \
-o "process detach" -o quit > "$PASEO_NATIVE_TEXT_REFLOW_LOG" 2>&1
grep '^NATIVE_REFLOW_' "$PASEO_NATIVE_TEXT_REFLOW_LOG"
grep -q '^NATIVE_REFLOW_PASS narrow=240 wide=500 selection=12:9$' "$PASEO_NATIVE_TEXT_REFLOW_LOG"
+2 -2
View File
@@ -1,5 +1,5 @@
import { expect, type Page } from "@playwright/test";
import { getStateLabel } from "@/git/pull-request-panel/data";
import { en } from "@/i18n/resources/en";
import { openPullRequestPanel } from "./workspace-tabs";
export async function openPrPane(page: Page): Promise<void> {
@@ -14,7 +14,7 @@ export async function expectPrPaneState(
page: Page,
state: "open" | "merged" | "closed" | "draft",
): Promise<void> {
await expect(page.getByTestId("pr-pane-state")).toHaveText(getStateLabel(state), {
await expect(page.getByTestId("pr-pane-state")).toHaveText(en.workspace.git.pr.states[state], {
timeout: 15_000,
});
}
+1 -1
View File
@@ -370,7 +370,7 @@ export async function findAcpCatalogProvider(page: Page, providerName: string):
export async function installAcpCatalogProvider(page: Page, providerName: string): Promise<void> {
await findAcpCatalogProvider(page, providerName);
await page.getByRole("button", { name: "Add", exact: true }).click();
await page.getByRole("button", { name: `Add ${providerName}`, exact: true }).click();
}
export async function expectProviderInstalledInSettings(
@@ -244,13 +244,16 @@ const FOOTER_GEOMETRY_TOLERANCE = 0.01;
* Settings together at the end.
*/
export async function expectFooterIconRow(page: Page): Promise<void> {
const boxes = (
await Promise.all(
FOOTER_ICON_TEST_IDS.map((testID) =>
page.locator(`[data-testid="${testID}"]:visible`).first().boundingBox(),
),
)
).map((box) => box!);
// Read one browser-frame snapshot: the compact drawer can translate between separate RPCs.
const boxes = await page
.locator(FOOTER_ICON_TEST_IDS.map((testID) => `[data-testid="${testID}"]:visible`).join(","))
.evaluateAll((elements) =>
elements.map((element) => {
const { x, y, width, height } = element.getBoundingClientRect();
return { testID: element.getAttribute("data-testid"), x, y, width, height };
}),
);
expect(boxes.map((box) => box.testID)).toEqual(FOOTER_ICON_TEST_IDS);
const [first] = boxes;
for (const box of boxes) {
expect(Math.abs(box.y + box.height / 2 - first!.y - first!.height / 2)).toBeLessThan(2);
@@ -297,11 +297,7 @@ export function findScrollJumps(frames: ScrollFrame[]) {
// Follow that intended row, not text now below the image.
const recent = frames.findLast((frame) => frame.at <= previous.at - 100);
const wheelBudget = current.wheelTotal - (recent?.wheelTotal ?? 0);
const frameScroll = Math.max(0, previous.scrollTop - current.scrollTop);
const availableScroll = Math.min(frameScroll, wheelBudget, previous.scrollTop);
const readingLine = 8 - availableScroll;
const currentRows = new Map(current.rows.map((row) => [row.id, row]));
const intendedRow = previous.rows.find((row) => row.top + row.height > readingLine);
const anchor = previous.anchor;
const before = currentRows.has(anchor)
? previous.rows.find((row) => row.id === anchor)!
@@ -316,25 +312,33 @@ export function findScrollJumps(frames: ScrollFrame[]) {
// A busy main thread can deliver wheel movement hundreds of milliseconds
// after its event. Only assert idle stability after the driver stops input.
const idle = current.inputFinishedAt !== null && previous.at - current.inputFinishedAt > 250;
// Wheel events can precede their scroll update, or include input already
// applied in earlier frames. Locate the reader with this frame's scroll,
// bounded by recent input. Growth below it can move the old anchor without
// moving the reading line.
const readerFollowsInput =
intendedRow &&
currentRows.has(intendedRow.id) &&
Math.abs(currentRows.get(intendedRow.id)!.top - intendedRow.top - availableScroll) <= 32;
const enteredRowGrowth = readerFollowsInput
// scrollTop also includes virtualizer estimate corrections. Locate the row
// entered by wheel input from its viewport movement instead. Its old height
// identifies the reader before a simultaneous image resize takes effect.
const intendedRow = previous.rows.find((row) => {
const next = currentRows.get(row.id);
if (!next || idle || wheelBudget <= 0) return false;
const travel = next.top - row.top;
return (
travel >= -8 &&
travel <= Math.min(wheelBudget, previous.scrollTop) + 32 &&
(next.top <= 8 || current.scrollTop === 0) &&
next.top + row.height > 8
);
});
const enteredRowResize = intendedRow
? previous.rows.reduce((growth, row) => {
if (row.top < intendedRow.top || row.top >= before.top) return growth;
const height = currentRows.get(row.id)?.height ?? row.height;
return growth + Math.max(0, height - row.height);
return growth + height - row.height;
}, 0)
: 0;
const excessForward = movement > wheelBudget + enteredRowGrowth + 32;
const readingMovement = movement - enteredRowResize;
const excessForward = readingMovement > wheelBudget + 32;
// Upward wheel input moves the same text DOWN the viewport. A negative move
// is a reversal, independent of legitimate scrollTop compensation on prepend.
if (movement >= -8 && (!idle || Math.abs(movement) <= 8) && !excessForward) return [];
if (readingMovement >= -8 && (!idle || Math.abs(readingMovement) <= 8) && !excessForward)
return [];
return [
{
frame: index,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.11.0-beta.5",
"version": "0.11.3",
"private": true,
"main": "index.ts",
"scripts": {
@@ -48,6 +48,8 @@ export const ACP_PROVIDER_ICON_SVGS = {
goose:
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24">\n <path fill="currentColor" d="M20.9093 19.3861L19.5185 18.2413C18.7624 17.619 18.1189 16.8713 17.6157 16.0313C16.9205 14.8706 15.9599 13.8912 14.8133 13.1735L14.2533 12.8475C14.0614 12.7141 13.9276 12.5062 13.9086 12.2716C13.8963 12.1204 13.9326 11.9852 14.0171 11.8662C14.3087 11.4553 15.896 9.74698 16.1722 9.51845C16.528 9.22442 16.9243 8.97987 17.2921 8.69986C17.3443 8.66 17.3968 8.62035 17.4485 8.57989C17.4503 8.57808 17.4529 8.57668 17.4545 8.57508C17.5725 8.48195 17.6838 8.383 17.7724 8.26563C18.2036 7.76631 18.195 7.3443 18.195 7.3443C18.195 7.3443 18.1954 7.3439 18.1956 7.3437C18.1497 7.23133 17.9847 6.88163 17.6492 6.71759C17.9458 6.71178 18.2805 6.82294 18.4323 6.97156C18.6148 6.68534 18.7328 6.49967 18.9162 6.18762C18.9599 6.11352 18.9831 5.97652 18.8996 5.89981C18.8996 5.89981 18.8992 5.89981 18.8988 5.89981C18.8988 5.89981 18.8988 5.8994 18.8988 5.899C18.8972 5.8974 18.8952 5.8962 18.8936 5.8946C18.892 5.893 18.891 5.89119 18.8892 5.88939C18.8892 5.88939 18.8888 5.88939 18.8884 5.88939C18.8884 5.88939 18.8884 5.88899 18.8884 5.88859C18.885 5.88518 18.8812 5.88258 18.8776 5.87938C18.8754 5.87717 18.8736 5.87457 18.8716 5.87217C18.8692 5.87016 18.8665 5.86836 18.8643 5.86616C18.8609 5.86275 18.8587 5.85855 18.8551 5.85534C18.8551 5.85534 18.8545 5.85514 18.8543 5.85534C18.8543 5.85534 18.8543 5.85494 18.8543 5.85454C18.8527 5.85294 18.8507 5.85174 18.8491 5.85013C18.8475 5.84853 18.8463 5.84653 18.8447 5.84493C18.8447 5.84493 18.8441 5.84473 18.8439 5.84493C18.8439 5.84493 18.8439 5.84453 18.8439 5.84413C18.7672 5.7606 18.6302 5.78384 18.5561 5.8275C18.1503 6.06625 17.7555 6.32322 17.3996 6.54855C17.3996 6.54855 16.9778 6.53973 16.4783 6.97116C16.3607 7.05989 16.2618 7.17125 16.1688 7.28902C16.167 7.29082 16.1654 7.29322 16.164 7.29503C16.1234 7.3465 16.0837 7.39898 16.0441 7.45145C15.7639 7.81939 15.5195 8.21556 15.2255 8.57128C14.9971 8.84768 13.2887 10.4348 12.8777 10.7264C12.7587 10.8109 12.6237 10.8474 12.4723 10.835C12.2379 10.8161 12.0298 10.6821 11.8965 10.4903L11.5704 9.93024C10.8527 8.78318 9.87332 7.82299 8.71264 7.12778C7.87262 6.62466 7.12514 5.98092 6.50264 5.22503L5.35778 3.83421C5.3013 3.76571 5.19314 3.77693 5.15268 3.85585C5.02249 4.10941 4.77393 4.64479 4.58346 5.36483C4.57885 5.38186 4.58286 5.39988 4.59407 5.4135C4.83082 5.69952 5.37901 6.32983 6.03196 6.863C6.07742 6.90005 6.04017 6.97336 5.98369 6.95774C5.42047 6.80432 4.87288 6.55796 4.46308 6.34805C4.42964 6.33103 4.38918 6.35226 4.38437 6.38951C4.32068 6.89985 4.30425 7.46027 4.37155 8.05112C4.37355 8.07035 4.38577 8.08697 4.4036 8.09479C4.87088 8.29808 5.61816 8.59311 6.40269 8.78078C6.45958 8.7944 6.45777 8.87632 6.40029 8.88733C5.78941 9.0023 5.14968 9.02794 4.62973 9.02113C4.59327 9.02073 4.56643 9.05518 4.57625 9.09023C4.6806 9.45896 4.822 9.8339 5.00847 10.2115C5.08559 10.3811 5.16951 10.5475 5.25944 10.7104C5.27486 10.7382 5.3047 10.7548 5.33655 10.7534C5.76577 10.7324 6.28452 10.6871 6.80608 10.595C6.89501 10.5794 6.94268 10.6964 6.86757 10.7466C6.51345 10.9834 6.13571 11.1873 5.7844 11.3551C5.73733 11.3777 5.72211 11.4378 5.75315 11.4797C5.96186 11.7625 6.19139 12.0301 6.44075 12.2794C6.44075 12.2794 7.66853 13.5441 7.70198 13.6432C8.41841 12.9096 9.59612 12.0964 10.8966 11.3864C9.15488 12.8036 8.18387 13.8499 7.69517 14.4444L7.35447 14.9225C7.17742 15.1708 7.02379 15.4346 6.89541 15.7112C6.46579 16.6356 5.75756 18.5051 5.75756 18.5051C5.70328 18.6515 5.74754 18.7959 5.84168 18.89C5.84388 18.8922 5.84609 18.8944 5.84849 18.8964C5.85069 18.8986 5.8527 18.901 5.8549 18.9032C5.94924 18.9976 6.09345 19.0416 6.23986 18.9874C6.23986 18.9874 8.10897 18.2791 9.03371 17.8495C9.31031 17.7211 9.57429 17.5673 9.82245 17.3905L10.349 17.0153C10.6278 16.8166 11.0096 16.8483 11.2517 17.0904L12.4655 18.3042C12.7148 18.5535 12.9824 18.7831 13.2652 18.9918C13.3073 19.0226 13.3672 19.0076 13.3898 18.9605C13.5579 18.6094 13.7618 18.2313 13.9983 17.8774C14.0486 17.8022 14.1657 17.8501 14.1499 17.9388C14.0576 18.4606 14.0127 18.9794 13.9915 19.4084C13.9899 19.44 14.0067 19.4701 14.0345 19.4855C14.1972 19.5756 14.3636 19.6595 14.5335 19.7364C14.911 19.9229 15.2862 20.0645 15.6547 20.1687C15.6897 20.1785 15.7242 20.1516 15.7238 20.1152C15.7168 19.595 15.7424 18.9553 15.8576 18.3446C15.8684 18.2869 15.9503 18.2851 15.9641 18.3422C16.1516 19.127 16.4466 19.8742 16.6501 20.3413C16.6579 20.3591 16.6744 20.3712 16.6938 20.3734C17.2847 20.4407 17.8451 20.4242 18.3554 20.3606C18.3929 20.3559 18.4141 20.3155 18.3969 20.2818C18.187 19.872 17.9406 19.3241 17.7872 18.7612C17.7718 18.7046 17.8449 18.6675 17.8819 18.713C18.4151 19.3659 19.0454 19.9141 19.3314 20.1508C19.345 20.1621 19.3633 20.1659 19.3801 20.1615C20.1003 19.9712 20.6357 19.7226 20.8891 19.5922C20.968 19.5518 20.9792 19.4436 20.9107 19.3871L20.9093 19.3861Z"/>\n</svg>\n',
grok: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 1024 1024" fill="none">\n <path fill="currentColor" d="M395.479 633.828L735.91 381.105C752.599 368.715 776.454 373.548 784.406 392.792C826.26 494.285 807.561 616.253 724.288 699.996C641.016 783.739 525.151 802.104 419.247 760.277L303.556 814.143C469.49 928.202 670.987 899.995 796.901 773.282C896.776 672.843 927.708 535.937 898.785 412.476L899.047 412.739C857.105 231.37 909.358 158.874 1016.4 10.6326C1018.93 7.11771 1021.47 3.60279 1024 0L883.144 141.651V141.212L395.392 633.916"/>\n <path fill="currentColor" d="M325.226 695.251C206.128 580.84 226.662 403.776 328.285 301.668C403.431 226.097 526.549 195.254 634.026 240.596L749.454 186.994C728.657 171.88 702.007 155.623 671.424 144.2C533.19 86.9942 367.693 115.465 255.323 228.382C147.234 337.081 113.244 504.215 171.613 646.833C215.216 753.423 143.739 828.818 71.7385 904.916C46.2237 931.893 20.6216 958.87 0 987.429L325.139 695.339"/>\n</svg>\n',
hoplite:
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="1.394 1.679 8.494 7.828" fill="currentColor"><path d="M8.237 7.813C8.276 7.789 8.327 7.796 8.358 7.83L9.741 9.346C9.797 9.407 9.754 9.506 9.671 9.507H8.977C8.924 9.507 8.881 9.463 8.881 9.41V9.125C8.881 9.089 8.861 9.057 8.83 9.04L7.589 8.379C7.524 8.344 7.521 8.251 7.584 8.212L8.237 7.813ZM3.944 1.679C4.502 1.679 5.052 1.82 5.54 2.09L5.962 2.323C6.024 2.358 6.029 2.446 5.97 2.486L5.091 3.09C5.065 3.108 5.05 3.137 5.05 3.169V3.495C5.05 3.529 5.032 3.561 5.003 3.578L4.68 3.769C4.594 3.82 4.498 3.722 4.549 3.637L4.592 3.566C4.63 3.502 4.584 3.421 4.51 3.421H4.028C3.925 3.421 3.894 3.562 3.989 3.604L4.377 3.775C4.448 3.806 4.455 3.904 4.389 3.944L3.77 4.323C3.755 4.332 3.737 4.337 3.72 4.337H3.361C3.309 4.337 3.268 4.379 3.268 4.431C3.268 4.484 3.309 4.526 3.361 4.526H3.779C3.797 4.526 3.814 4.521 3.829 4.513L7.599 2.272C8.135 1.954 8.815 2.008 9.294 2.407C9.307 2.418 9.314 2.434 9.314 2.451V2.729C9.315 2.812 9.413 2.856 9.475 2.8L9.728 2.57C9.789 2.514 9.888 2.558 9.888 2.641V5.812C9.888 5.865 9.845 5.908 9.792 5.908H9.359C9.331 5.908 9.303 5.895 9.285 5.873L8.374 4.773C8.337 4.728 8.268 4.728 8.229 4.771L7.468 5.625C7.429 5.669 7.438 5.737 7.487 5.769L9.038 6.776C9.098 6.815 9.096 6.903 9.035 6.939L6.618 8.363C6.556 8.4 6.478 8.358 6.474 8.286L6.439 7.633C6.437 7.578 6.48 7.532 6.535 7.532H6.925C6.948 7.532 6.971 7.523 6.988 7.508L7.211 7.31C7.276 7.252 7.236 7.144 7.148 7.142L5.991 7.118H1.49C1.437 7.118 1.394 7.075 1.394 7.022V3.308C1.394 3.255 1.437 3.212 1.49 3.212H2.544C2.597 3.212 2.64 3.169 2.64 3.116V2.969C2.64 2.916 2.597 2.873 2.544 2.873H1.461C1.366 2.873 1.329 2.75 1.408 2.697L2.134 2.221C2.672 1.867 3.301 1.679 3.944 1.679Z"/></svg>\n',
junie:
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 40 40" fill="none">\n <path d="M25 15H35V16.75C35 29 30.5001 35 16.5001 35H15V25H16.5001C22.6251 25 25 22.875 25 16.75V15Z" fill="currentColor"/>\n <rect x="5" y="15" width="10" height="10" fill="currentColor"/>\n <rect x="15" y="5" width="10" height="10" fill="currentColor"/>\n</svg>\n',
kilo: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 40 40">\n <path fill="currentColor" d="M27.9998324,34.6666921h6.6666279v5.3333023h-8.3809037l-3.6190266-3.6190266v-8.3809037h5.3333023v6.6666279ZM39.9997627,26.2857884l-3.6190266-3.6190266h-8.3809037v5.3333023h6.6666279v6.6666279h5.3333023v-8.3809037ZM17.3332326,22.6667578h-5.3333023v5.3333023h5.3333023v-5.3333023ZM0,36.3809638l3.6190266,3.6190266h13.714206v-5.3333023H5.3333023v-11.9999302H0v13.714206ZM33.9600959,11.9999247V3.619021L30.3410693-.0000056h-7.6745392v5.3333023h5.9602634v6.6666279h-5.9602634v5.3333023h17.3332326v-5.3333023h-6.0396668ZM5.3333023,0H0v17.3332326h5.3333023v-5.9999651h6.6666279v5.9999651h5.3333023v-5.9999651l-5.3333023-5.3333023h-6.6666279V0ZM17.3332326,0h-5.3333023v5.9999651h5.3333023V0Z"/>\n</svg>\n',
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="1.394 1.679 8.494 7.828" fill="currentColor"><path d="M8.237 7.813C8.276 7.789 8.327 7.796 8.358 7.83L9.741 9.346C9.797 9.407 9.754 9.506 9.671 9.507H8.977C8.924 9.507 8.881 9.463 8.881 9.41V9.125C8.881 9.089 8.861 9.057 8.83 9.04L7.589 8.379C7.524 8.344 7.521 8.251 7.584 8.212L8.237 7.813ZM3.944 1.679C4.502 1.679 5.052 1.82 5.54 2.09L5.962 2.323C6.024 2.358 6.029 2.446 5.97 2.486L5.091 3.09C5.065 3.108 5.05 3.137 5.05 3.169V3.495C5.05 3.529 5.032 3.561 5.003 3.578L4.68 3.769C4.594 3.82 4.498 3.722 4.549 3.637L4.592 3.566C4.63 3.502 4.584 3.421 4.51 3.421H4.028C3.925 3.421 3.894 3.562 3.989 3.604L4.377 3.775C4.448 3.806 4.455 3.904 4.389 3.944L3.77 4.323C3.755 4.332 3.737 4.337 3.72 4.337H3.361C3.309 4.337 3.268 4.379 3.268 4.431C3.268 4.484 3.309 4.526 3.361 4.526H3.779C3.797 4.526 3.814 4.521 3.829 4.513L7.599 2.272C8.135 1.954 8.815 2.008 9.294 2.407C9.307 2.418 9.314 2.434 9.314 2.451V2.729C9.315 2.812 9.413 2.856 9.475 2.8L9.728 2.57C9.789 2.514 9.888 2.558 9.888 2.641V5.812C9.888 5.865 9.845 5.908 9.792 5.908H9.359C9.331 5.908 9.303 5.895 9.285 5.873L8.374 4.773C8.337 4.728 8.268 4.728 8.229 4.771L7.468 5.625C7.429 5.669 7.438 5.737 7.487 5.769L9.038 6.776C9.098 6.815 9.096 6.903 9.035 6.939L6.618 8.363C6.556 8.4 6.478 8.358 6.474 8.286L6.439 7.633C6.437 7.578 6.48 7.532 6.535 7.532H6.925C6.948 7.532 6.971 7.523 6.988 7.508L7.211 7.31C7.276 7.252 7.236 7.144 7.148 7.142L5.991 7.118H1.49C1.437 7.118 1.394 7.075 1.394 7.022V3.308C1.394 3.255 1.437 3.212 1.49 3.212H2.544C2.597 3.212 2.64 3.169 2.64 3.116V2.969C2.64 2.916 2.597 2.873 2.544 2.873H1.461C1.366 2.873 1.329 2.75 1.408 2.697L2.134 2.221C2.672 1.867 3.301 1.679 3.944 1.679Z"/></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

@@ -1,19 +1,21 @@
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Pressable, Text, View } from "react-native";
import { Text, View, type LayoutChangeEvent } from "react-native";
import { SvgXml } from "react-native-svg";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { ExternalLink, PackagePlus, Search } from "lucide-react-native";
import { PackagePlus } from "lucide-react-native";
import { Button } from "@/components/ui/button";
import { isWeb } from "@/constants/platform";
import { ExternalLink } from "@/components/ui/external-link";
import { SearchField } from "@/components/ui/search-field";
import { StatusBadge } from "@/components/ui/status-badge";
import { rankProviderCatalog } from "@/components/provider-catalog-search";
import {
useAcpProviderCatalog,
type AcpProviderCatalogItem,
} from "@/hooks/use-acp-provider-catalog";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import type { Theme } from "@/styles/theme";
import { openExternalUrl } from "@/utils/open-external-url";
import { EditingTextInput as TextInput } from "@/components/ui/text-input";
import { SPACING, type Theme } from "@/styles/theme";
import { settingsStyles } from "@/styles/settings";
interface ProviderCatalogListProps {
serverId: string;
@@ -21,52 +23,43 @@ interface ProviderCatalogListProps {
onInstall: (entry: AcpProviderCatalogItem) => Promise<void> | void;
}
const SEARCH_ICON_SIZE = 16;
const PROVIDER_FALLBACK_ICON_SIZE = 20;
const PROVIDER_REMOTE_ICON_SIZE = 24;
const TILE_GAP = SPACING[3];
/** Narrowest tile that keeps the icon, type badge, install link, and Add on one row. */
const MIN_TILE_WIDTH = 300;
const MAX_COLUMNS = 4;
const ThemedPackagePlus = withUnistyles(PackagePlus);
const ThemedSvgXml = withUnistyles(SvgXml);
const ThemedSearch = withUnistyles(Search);
const ThemedExternalLink = withUnistyles(ExternalLink);
const ThemedTextInput = withUnistyles(TextInput, (theme) => ({
placeholderTextColor: theme.colors.foregroundMuted,
}));
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const foregroundMutedColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
});
function matchesSearch(entry: AcpProviderCatalogItem, query: string): boolean {
const normalized = query.trim().toLowerCase();
if (!normalized) return true;
return [entry.title, entry.id, entry.description].some((value) =>
value.toLowerCase().includes(normalized),
);
function columnCountForWidth(width: number): number {
const fits = Math.floor((width + TILE_GAP) / (MIN_TILE_WIDTH + TILE_GAP));
return Math.min(MAX_COLUMNS, Math.max(1, fits));
}
interface CatalogRowProps {
interface CatalogTileProps {
entry: AcpProviderCatalogItem;
/** `undefined` until the grid is measured, or in a single column; the tile fills the row. */
width: number | undefined;
installing: boolean;
onInstall: (entry: AcpProviderCatalogItem) => void;
}
function CatalogRow({ entry, installing, onInstall }: CatalogRowProps) {
function CatalogTile({ entry, width, installing, onInstall }: CatalogTileProps) {
const { t } = useTranslation();
const actionLabel = installing
? t("providerCatalog.actions.adding")
: t("providerCatalog.actions.add");
const handleInstall = useCallback(() => {
onInstall(entry);
}, [entry, onInstall]);
const handleOpenInstallLink = useCallback(() => {
void openExternalUrl(entry.installLink);
}, [entry.installLink]);
const tileStyle = useMemo(
() => [settingsStyles.card, styles.tile, width === undefined ? styles.tileFull : { width }],
[width],
);
return (
<View style={styles.row}>
<View style={tileStyle} testID={`provider-catalog-tile-${entry.id}`}>
<View style={styles.iconFrame}>
{entry.iconSvg ? (
<ThemedSvgXml
@@ -79,42 +72,33 @@ function CatalogRow({ entry, installing, onInstall }: CatalogRowProps) {
<ThemedPackagePlus size={PROVIDER_FALLBACK_ICON_SIZE} uniProps={foregroundColorMapping} />
)}
</View>
<View style={styles.textColumn}>
<View style={styles.titleRow}>
<Text style={styles.name} numberOfLines={1}>
{entry.title}
</Text>
<Text style={styles.version} numberOfLines={1}>
{entry.version}
</Text>
</View>
<Text style={styles.description} numberOfLines={1}>
{entry.description || entry.id}
<View style={styles.details}>
<Text style={styles.name} numberOfLines={1}>
{entry.title}
</Text>
<Pressable
accessibilityRole="link"
accessibilityLabel={t("providerCatalog.actions.installInstructionsFor", {
provider: entry.title,
})}
onPress={handleOpenInstallLink}
style={styles.installLink}
>
<Text style={styles.installLinkText} numberOfLines={1}>
{t("providerCatalog.actions.installInstructions")}
</Text>
<ThemedExternalLink size={12} uniProps={foregroundMutedColorMapping} />
</Pressable>
<View style={styles.metaRow}>
{/* The catalog is the ACP registry, so every entry it offers is an ACP provider. */}
<StatusBadge label={t("providerCatalog.types.acp")} size="xs" />
<ExternalLink
href={entry.installLink}
label={t("providerCatalog.actions.installInstructions")}
accessibilityLabel={t("providerCatalog.actions.installInstructionsFor", {
provider: entry.title,
})}
/>
</View>
</View>
<Button
size="sm"
variant="default"
size="xs"
variant="outline"
disabled={installing}
loading={installing}
onPress={handleInstall}
style={styles.actionButton}
accessibilityLabel={t("providerCatalog.actions.addProvider", { provider: entry.title })}
style={styles.addButton}
testID={`install-provider-${entry.id}`}
>
{actionLabel}
{installing ? t("providerCatalog.actions.adding") : t("providerCatalog.actions.add")}
</Button>
</View>
);
@@ -129,6 +113,7 @@ export function ProviderCatalogList({
const { entries: catalogEntries } = useAcpProviderCatalog();
const { entries: providerEntries } = useProvidersSnapshot(serverId);
const [search, setSearch] = useState("");
const [gridWidth, setGridWidth] = useState<number | null>(null);
const installedIds = useMemo(
() => new Set(providerEntries?.map((entry) => entry.provider) ?? []),
@@ -137,41 +122,48 @@ export function ProviderCatalogList({
const availableEntries = useMemo(
() =>
catalogEntries
.filter((entry) => !installedIds.has(entry.id))
.filter((entry) => matchesSearch(entry, search)),
rankProviderCatalog(
catalogEntries.filter((entry) => !installedIds.has(entry.id)),
search,
),
[catalogEntries, installedIds, search],
);
const handleGridLayout = useCallback((event: LayoutChangeEvent) => {
setGridWidth(event.nativeEvent.layout.width);
}, []);
const tileWidth = useMemo(() => {
if (gridWidth === null) return undefined;
const columns = columnCountForWidth(gridWidth);
if (columns === 1) return undefined;
return Math.floor((gridWidth - TILE_GAP * (columns - 1)) / columns);
}, [gridWidth]);
return (
<View>
<View style={styles.searchField}>
<View style={styles.searchIcon}>
<ThemedSearch size={SEARCH_ICON_SIZE} uniProps={foregroundMutedColorMapping} />
</View>
<ThemedTextInput
testID="provider-catalog-search"
initialValue={search}
<View style={styles.searchRail}>
<SearchField
value={search}
onChangeText={setSearch}
accessibilityLabel={t("providerCatalog.search")}
placeholder={t("providerCatalog.search")}
// @ts-expect-error - outlineStyle is web-only
style={[styles.searchInput, isWeb && { outlineStyle: "none" }]}
autoCapitalize="none"
autoCorrect={false}
clearAccessibilityLabel={t("providerCatalog.clearSearch")}
testID="provider-catalog-search"
clearTestID="provider-catalog-search-clear"
/>
</View>
{availableEntries.length === 0 ? (
<View style={styles.stateBox}>
<View style={[settingsStyles.card, styles.stateBox]}>
<Text style={styles.stateText}>{t("providerCatalog.noProviders")}</Text>
</View>
) : (
<View style={styles.list}>
<View style={styles.grid} onLayout={handleGridLayout}>
{availableEntries.map((entry) => (
<CatalogRow
<CatalogTile
key={entry.id}
entry={entry}
width={tileWidth}
installing={installingProviderId === entry.id}
onInstall={onInstall}
/>
@@ -183,101 +175,56 @@ export function ProviderCatalogList({
}
const styles = StyleSheet.create((theme) => ({
searchField: {
searchRail: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.lg,
borderWidth: 1,
borderColor: theme.colors.border,
paddingHorizontal: theme.spacing[3],
marginBottom: theme.spacing[3],
},
searchIcon: {
width: 18,
alignItems: "center",
grid: {
flexDirection: "row",
flexWrap: "wrap",
gap: TILE_GAP,
},
searchInput: {
flex: 1,
minWidth: 0,
paddingVertical: theme.spacing[3],
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
},
list: {
borderRadius: theme.borderRadius.lg,
borderWidth: 1,
borderColor: theme.colors.border,
overflow: "hidden",
},
row: {
tile: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[3],
padding: theme.spacing[3],
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
},
tileFull: {
width: "100%",
},
details: {
flex: 1,
minWidth: 0,
gap: theme.spacing[1],
},
iconFrame: {
flexShrink: 0,
width: 36,
height: 36,
borderRadius: theme.borderRadius.md,
backgroundColor: theme.colors.surface2,
alignItems: "center",
justifyContent: "center",
},
addButton: {
flexShrink: 0,
},
textColumn: {
flex: 1,
minWidth: 0,
gap: theme.spacing[1],
},
titleRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
minWidth: 0,
},
name: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.medium,
flexShrink: 1,
fontWeight: theme.fontWeight.normal,
},
version: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
flexShrink: 0,
},
description: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
installLink: {
alignSelf: "flex-start",
metaRow: {
flexDirection: "row",
flexWrap: "wrap",
alignItems: "center",
gap: theme.spacing[1],
maxWidth: "100%",
},
installLinkText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
actionButton: {
width: 92,
flexShrink: 0,
gap: theme.spacing[2],
},
stateBox: {
minHeight: 96,
borderRadius: theme.borderRadius.lg,
borderWidth: 1,
borderColor: theme.colors.border,
backgroundColor: theme.colors.surface1,
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[3],
padding: theme.spacing[4],
},
stateText: {
@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import { rankProviderCatalog } from "./provider-catalog-search";
const entries = [
{ id: "kiro-cli", title: "Kiro CLI" },
{ id: "goose", title: "goose" },
{ id: "kimi", title: "Kimi Code CLI" },
{ id: "gemini", title: "Gemini CLI" },
{ id: "qwen-code", title: "Qwen Code" },
{ id: "codex-acp", title: "Codex" },
];
function titles(list: { title: string }[]): string[] {
return list.map((entry) => entry.title);
}
describe("rankProviderCatalog", () => {
it("lists every provider alphabetically by name without a query", () => {
expect(titles(rankProviderCatalog(entries, " "))).toEqual([
"Codex",
"Gemini CLI",
"goose",
"Kimi Code CLI",
"Kiro CLI",
"Qwen Code",
]);
});
it("ranks the strongest match first and drops providers that do not match", () => {
expect(titles(rankProviderCatalog(entries, "cod"))).toEqual([
"Codex",
"Kimi Code CLI",
"Qwen Code",
]);
});
it("matches in-word subsequences and multiple tokens like the other pickers", () => {
expect(titles(rankProviderCatalog(entries, "gmni"))).toEqual(["Gemini CLI"]);
expect(titles(rankProviderCatalog(entries, "qw cod"))).toEqual(["Qwen Code"]);
});
it("matches the provider id", () => {
expect(titles(rankProviderCatalog(entries, "kiro-cli"))).toEqual(["Kiro CLI"]);
});
});
@@ -0,0 +1,34 @@
import {
compareMatchScores,
scoreTextFields,
type MatchScore,
} from "@getpaseo/protocol/search/text-match";
export interface SearchableCatalogEntry {
id: string;
title: string;
}
function compareTitles(a: SearchableCatalogEntry, b: SearchableCatalogEntry): number {
return a.title.localeCompare(b.title, undefined, { sensitivity: "base" });
}
/**
* Orders the Add provider catalog. Without a query the list is alphabetical by
* name. With a query it uses the pickers' ranked matcher over the fields the
* tile shows (name and id), best match first, ties alphabetical.
*/
export function rankProviderCatalog<T extends SearchableCatalogEntry>(
entries: readonly T[],
query: string,
): T[] {
if (!query.trim()) return [...entries].sort(compareTitles);
const scored: { entry: T; score: MatchScore }[] = [];
for (const entry of entries) {
const score = scoreTextFields(query, [entry.title, entry.id]);
if (score) scored.push({ entry, score });
}
scored.sort((a, b) => compareMatchScores(a.score, b.score) || compareTitles(a.entry, b.entry));
return scored.map(({ entry }) => entry);
}
@@ -50,7 +50,7 @@ import { useToast } from "@/contexts/toast-context";
import { toErrorMessage } from "@/utils/error-messages";
import { showProviderNoticeToast } from "@/utils/provider-notice-toast";
import { applyCheckoutStatusUpdateFromEvent } from "@/git/checkout-status-cache";
import { useProviderSubagentStore } from "@/subagents/provider-store";
import { resyncProviderSubagents, useProviderSubagentStore } from "@/subagents/provider-store";
// Re-export types from session-store and draft-store for backward compatibility
export type { DraftInput } from "@/stores/draft-store";
@@ -532,9 +532,14 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
}
});
const unsubProviderSubagentUpdate = onFeed("agent.provider_subagents.update", (message) => {
if (message.type !== "agent.provider_subagents.update") return;
useProviderSubagentStore.getState().applyUpdate(serverId, message.payload);
const unsubProviderSubagentUpdate = feeds.subscribe({
snapshot: () => {
void resyncProviderSubagents(client, serverId);
},
update: (message) => {
if (message.type !== "agent.provider_subagents.update") return;
useProviderSubagentStore.getState().applyUpdate(serverId, message.payload);
},
});
const unsubCheckoutStatusUpdate = onFeed("checkout_status_update", (message) => {
@@ -8,6 +8,7 @@ export interface AcpProviderCatalogEntry {
iconSvg: string | null;
installLink: string;
command: readonly [string, ...string[]];
bundledProvider?: boolean;
env?: Readonly<Record<string, string>>;
params?: Readonly<Record<string, unknown>>;
}
@@ -238,6 +239,16 @@ const CATALOG_DATA = [
installLink: "https://hermes-agent.nousresearch.com/docs/user-guide/features/acp",
command: ["hermes", "acp"],
},
{
id: "hoplite",
bundledProvider: true,
title: "Hoplite",
description: "Cloud coding agent through the local hoplite acp bridge",
version: "manual",
iconId: "hoplite",
installLink: "https://hoplite.sh/docs/cli#install",
command: ["hoplite", "acp"],
},
{
id: "junie",
title: "Junie",
@@ -389,6 +400,7 @@ export const ACP_PROVIDER_CATALOG: AcpProviderCatalogEntry[] = CATALOG_DATA.map(
version: entry.version,
installLink: entry.installLink,
command: entry.command,
bundledProvider: "bundledProvider" in entry ? entry.bundledProvider : undefined,
env: "env" in entry ? entry.env : undefined,
params: "params" in entry ? entry.params : undefined,
iconSvg: entry.iconId ? (ACP_PROVIDER_ICON_SVGS[entry.iconId] ?? null) : null,
@@ -9,12 +9,13 @@ import {
isPipelineActiveStatus,
mapPipelineStatus,
} from "@/git/forges/gitlab";
import { i18n } from "@/i18n/i18next";
import { IDENTITY_COLOR_NAMES, identityColor } from "@/styles/identity-colors";
import {
deriveAvatarColor,
formatAge,
getActivityVerb,
getStateLabel,
getActivityVerbKey,
getStateLabelKey,
mapPrPaneData,
} from "./data";
@@ -779,37 +780,34 @@ describe("formatAge", () => {
});
});
describe("getStateLabel", () => {
const english = i18n.getFixedT("en");
const french = i18n.getFixedT("fr");
describe("getStateLabelKey", () => {
it.each([
["open", "Open"],
["draft", "Draft"],
["merged", "Merged"],
["closed", "Closed"],
] as const)("maps %s → %s", (state, expected) => {
expect(getStateLabel(state)).toBe(expected);
["open", "Open", "Ouverte"],
["draft", "Draft", "Brouillon"],
["merged", "Merged", "Fusionnée"],
["closed", "Closed", "Fermée"],
] as const)("labels %s as %s, and %s in French", (state, englishLabel, frenchLabel) => {
expect(english(getStateLabelKey(state))).toBe(englishLabel);
expect(french(getStateLabelKey(state))).toBe(frenchLabel);
});
});
describe("getActivityVerb", () => {
it("returns Commented for comment kind", () => {
expect(getActivityVerb({ kind: "comment" })).toBe("Commented");
});
it("returns Approved for approved review", () => {
expect(getActivityVerb({ kind: "review", reviewState: "approved" })).toBe("Approved");
});
it("returns Requested changes for changes_requested review", () => {
expect(getActivityVerb({ kind: "review", reviewState: "changes_requested" })).toBe(
describe("getActivityVerbKey", () => {
it.each([
[{ kind: "comment" }, "Commented", "A commenté"],
[{ kind: "review", reviewState: "approved" }, "Approved", "A approuvé"],
[
{ kind: "review", reviewState: "changes_requested" },
"Requested changes",
);
});
it("returns Reviewed for a commented review with body (generic case)", () => {
expect(getActivityVerb({ kind: "review", reviewState: "commented" })).toBe("Reviewed");
});
it("returns Reviewed when reviewState is undefined", () => {
expect(getActivityVerb({ kind: "review" })).toBe("Reviewed");
"A demandé des modifications",
],
[{ kind: "review", reviewState: "commented" }, "Reviewed", "A relu"],
[{ kind: "review" }, "Reviewed", "A relu"],
] as const)("labels %o as %s, and %s in French", (item, englishVerb, frenchVerb) => {
expect(english(getActivityVerbKey(item))).toBe(englishVerb);
expect(french(getActivityVerbKey(item))).toBe(frenchVerb);
});
});
+12 -10
View File
@@ -318,16 +318,18 @@ function parsePullRequestNumber(url: string): number | null {
}
}
export function getStateLabel(state: PrState): string {
if (state === "draft") return "Draft";
if (state === "merged") return "Merged";
if (state === "closed") return "Closed";
return "Open";
export function getStateLabelKey(state: PrState) {
if (state === "draft") return "workspace.git.pr.states.draft";
if (state === "merged") return "workspace.git.pr.states.merged";
if (state === "closed") return "workspace.git.pr.states.closed";
return "workspace.git.pr.states.open";
}
export function getActivityVerb(item: Pick<PrPaneActivity, "kind" | "reviewState">): string {
if (item.kind === "comment") return "Commented";
if (item.reviewState === "approved") return "Approved";
if (item.reviewState === "changes_requested") return "Requested changes";
return "Reviewed";
export function getActivityVerbKey(item: Pick<PrPaneActivity, "kind" | "reviewState">) {
if (item.kind === "comment") return "workspace.git.pr.activity.commented";
if (item.reviewState === "approved") return "workspace.git.pr.activity.approved";
if (item.reviewState === "changes_requested") {
return "workspace.git.pr.activity.requestedChanges";
}
return "workspace.git.pr.activity.reviewed";
}
@@ -63,7 +63,7 @@ import {
canAddPullRequestActivityToChat,
} from "./context-attachment";
import { ChecksSection, getCheckIdentity } from "./checks-section";
import { getActivityVerb, getStateLabel } from "./data";
import { getActivityVerbKey, getStateLabelKey } from "./data";
import type { PrPaneActivity, PrPaneCheck, PrPaneData, PrState } from "./data";
import type { ForgeSpecificStatusFacts } from "@/git/merge-capability";
import { CheckPresentationIcon } from "@/git/check-presentation.view";
@@ -538,7 +538,7 @@ export function PullRequestPane({
<View style={styles.metaLine}>
<StateIcon size={14} uniProps={statePresentation.iconColor} />
<Text style={stateLabelStyle(data.state)} testID="pr-pane-state">
{getStateLabel(data.state)}
{t(getStateLabelKey(data.state))}
</Text>
{nativeHeaderMeta}
{repoIdentity ? (
@@ -760,7 +760,8 @@ function ActivityAvatar({ activity, size }: { activity: PrPaneActivity; size: nu
}
function ActivityVerb({ activity }: { activity: PrPaneActivity }) {
const verb = getActivityVerb(activity).toLowerCase();
const { t } = useTranslation();
const verb = t(getActivityVerbKey(activity)).toLowerCase();
if (activity.kind === "review" && activity.reviewState === "approved") {
return (
<View style={styles.verbGroup}>
@@ -1065,8 +1066,12 @@ function ThreadBlock({
? formatPullRequestThreadPath(thread.location)
: t("workspace.git.pr.thread.discussion")}
</Text>
{thread.isResolved ? <StatusBadge label="Resolved" variant="success" /> : null}
{thread.location?.isOutdated ? <StatusBadge label="Outdated" /> : null}
{thread.isResolved ? (
<StatusBadge label={t("workspace.git.pr.thread.resolved")} variant="success" />
) : null}
{thread.location?.isOutdated ? (
<StatusBadge label={t("workspace.git.pr.thread.outdated")} />
) : null}
<View style={styles.headerTrailing}>
{collapsed ? (
<View style={styles.threadCount}>
@@ -44,6 +44,7 @@ describe("ACP provider catalog", () => {
expect(findProvider("codewhale").command).toEqual(["codewhale", "serve", "--acp"]);
expect(findProvider("devin").command).toEqual(["devin", "acp"]);
expect(findProvider("goose").command).toEqual(["goose", "acp"]);
expect(findProvider("hoplite").command).toEqual(["hoplite", "acp"]);
expect(findProvider("junie").command).toEqual(["junie", "--acp", "true"]);
expect(findProvider("kiro").command).toEqual(["kiro-cli", "acp"]);
expect(findProvider("poolside").command).toEqual(["pool", "acp"]);
@@ -59,6 +60,32 @@ describe("ACP provider catalog", () => {
expect(findProvider("minimax-code").iconSvg).toContain("<svg");
});
it("offers Hoplite through its local ACP bridge with the Hoplite mark", () => {
expect(findProvider("hoplite")).toMatchObject({
title: "Hoplite",
command: ["hoplite", "acp"],
installLink: "https://hoplite.sh/docs/cli#install",
});
expect(buildAcpProviderConfigPatch(findProvider("hoplite"), true)).toEqual({
providers: {
hoplite: { enabled: true },
},
});
const ids = getAcpProviderCatalog().map((entry) => entry.id);
expect(ids.indexOf("hermes")).toBeLessThan(ids.indexOf("hoplite"));
expect(ids.indexOf("hoplite")).toBeLessThan(ids.indexOf("junie"));
expect(findProvider("hoplite").iconSvg).toContain('fill="currentColor"');
});
it.each([undefined, false])(
"rejects unsupported bundled Hoplite before producing a patch (%s)",
(supported) => {
expect(() => buildAcpProviderConfigPatch(findProvider("hoplite"), supported)).toThrow(
"Update the host to add Hoplite.",
);
},
);
it("maps a catalog entry to the daemon provider config patch", () => {
expect(buildAcpProviderConfigPatch(findProvider("amp-acp"))).toEqual({
providers: {
@@ -10,7 +10,13 @@ export function getAcpProviderCatalog(): AcpProviderCatalogItem[] {
export function buildAcpProviderConfigPatch(
entry: AcpProviderCatalogItem,
supportsHopliteProvider = false,
): MutableDaemonConfigPatch {
if (entry.bundledProvider) {
// COMPAT(hopliteProvider): added after v0.11.2, remove after 2027-04-15 once host floor includes the bundled adapter.
if (!supportsHopliteProvider) throw new Error("Update the host to add Hoplite.");
return { providers: { [entry.id]: { enabled: true } } };
}
return {
providers: {
[entry.id]: {
+19
View File
@@ -76,6 +76,7 @@ const untranslatedLocalFallbacks = [
const pullRequestPanelSources = [
"git/pull-request-panel/pane.tsx",
"git/pull-request-panel/data.ts",
"git/pull-request-panel/checks-section.tsx",
"components/sidebar/sidebar-status-list.tsx",
] as const;
@@ -87,6 +88,16 @@ const untranslatedPullRequestPanelLabels = [
"Adding...",
"Comment actions",
"Thread actions",
"Commented",
"Approved",
"Requested changes",
"Reviewed",
"Draft",
"Merged",
"Closed",
"Open",
"Resolved",
"Outdated",
] as const;
function findUntranslatedPullRequestPanelLabels(): string[] {
@@ -244,6 +255,14 @@ describe("translation resources", () => {
expect(pr.actions.addingToChat).not.toBe(englishPr.actions.addingToChat);
expect(pr.accessibility.commentActions).not.toBe(englishPr.accessibility.commentActions);
expect(pr.accessibility.threadActions).not.toBe(englishPr.accessibility.threadActions);
for (const verb of Object.keys(englishPr.activity) as (keyof typeof englishPr.activity)[]) {
expect(pr.activity[verb]).not.toBe(englishPr.activity[verb]);
}
for (const state of Object.keys(englishPr.states) as (keyof typeof englishPr.states)[]) {
expect(pr.states[state]).not.toBe(englishPr.states[state]);
}
expect(pr.thread.resolved).not.toBe(englishPr.thread.resolved);
expect(pr.thread.outdated).not.toBe(englishPr.thread.outdated);
expect(resource.sidebar.statusGroupAccessibility).not.toBe(
en.sidebar.statusGroupAccessibility,
);
+7
View File
@@ -1070,6 +1070,8 @@ export const ar: TranslationResources = {
},
thread: {
discussion: "سلسلة المناقشة",
resolved: "تم الحل",
outdated: "قديم",
},
errors: {
statusLoadFailed: "غير قادر على تحميل حالة طلب السحب",
@@ -1648,14 +1650,19 @@ export const ar: TranslationResources = {
title: "إضافة مزود",
search: "مقدمي البحث",
noProviders: "لم يتم العثور على مقدمي الخدمات",
clearSearch: "مسح البحث",
actions: {
add: "يضيف",
addProvider: "إضافة {{provider}}",
adding: "إضافة",
installed: "تم التثبيت",
cancel: "يلغي",
installInstructions: "تعليمات التثبيت",
installInstructionsFor: "تعليمات تثبيت{{provider}}",
},
types: {
acp: "ACP",
},
errors: {
unableToInstall: "غير قادر على تثبيت الموفر",
},
+8 -1
View File
@@ -1078,6 +1078,8 @@ export const en = {
},
thread: {
discussion: "Discussion thread",
resolved: "Resolved",
outdated: "Outdated",
},
errors: {
statusLoadFailed: "Unable to load pull request status",
@@ -1673,14 +1675,19 @@ export const en = {
title: "Add provider",
search: "Search providers",
noProviders: "No providers found",
clearSearch: "Clear search",
actions: {
add: "Add",
addProvider: "Add {{provider}}",
adding: "Adding",
installed: "Installed",
cancel: "Cancel",
installInstructions: "Install instructions",
installInstructionsFor: "{{provider}} install instructions",
},
types: {
acp: "ACP",
},
errors: {
unableToInstall: "Unable to install provider",
},
@@ -2160,7 +2167,7 @@ export const en = {
globalTitle: "Enable plugins",
globalHint: "Global switch for every configured plugin",
sourceLabel: "Plugin source",
sourcePlaceholder: "Directory, Git URL, or npm package",
sourcePlaceholder: "owner/slug, directory, Git URL, or npm package",
docs: "Docs",
install: "Install plugin",
installing: "Installing…",
+11 -4
View File
@@ -1093,16 +1093,18 @@ export const es: TranslationResources = {
open: "Abierto",
},
activity: {
commented: "Comentado",
approved: "Aprobado",
requestedChanges: "Cambios solicitados",
reviewed: "Revisado",
commented: "Comentó",
approved: "Aprobó",
requestedChanges: "Solicitó cambios",
reviewed: "Revisó",
},
time: {
justNow: "En este momento",
},
thread: {
discussion: "Hilo de discusión",
resolved: "Resuelto",
outdated: "Desactualizado",
},
errors: {
statusLoadFailed: "No se puede cargar el estado de la solicitud de extracción",
@@ -1694,14 +1696,19 @@ export const es: TranslationResources = {
title: "Agregar proveedor",
search: "Proveedores de búsqueda",
noProviders: "No se encontraron proveedores",
clearSearch: "Borrar búsqueda",
actions: {
add: "Agregar",
addProvider: "Añadir {{provider}}",
adding: "Añadiendo",
installed: "Instalado",
cancel: "Cancelar",
installInstructions: "Instrucciones de instalación",
installInstructionsFor: "Instrucciones de instalación de{{provider}}",
},
types: {
acp: "ACP",
},
errors: {
unableToInstall: "No se puede instalar el proveedor",
},
+7
View File
@@ -1094,6 +1094,8 @@ export const fr: TranslationResources = {
},
thread: {
discussion: "Fil de discussion",
resolved: "Résolu",
outdated: "Obsolète",
},
errors: {
statusLoadFailed: "Impossible de charger le statut de la pull request",
@@ -1690,14 +1692,19 @@ export const fr: TranslationResources = {
title: "Ajouter un fournisseur",
search: "Rechercher des fournisseurs",
noProviders: "Aucun fournisseur trouvé",
clearSearch: "Effacer la recherche",
actions: {
add: "Ajouter",
addProvider: "Ajouter {{provider}}",
adding: "Ajout…",
installed: "Installé",
cancel: "Annuler",
installInstructions: "Instructions d’installation",
installInstructionsFor: "Instructions d’installation de {{provider}}",
},
types: {
acp: "ACP",
},
errors: {
unableToInstall: "Impossible d’installer le fournisseur",
},
+7
View File
@@ -1083,6 +1083,8 @@ export const ja: TranslationResources = {
},
thread: {
discussion: "ディスカッションスレッド",
resolved: "解決済み",
outdated: "古い",
},
errors: {
statusLoadFailed: "プルリクエストのステータスを読み込めません",
@@ -1665,14 +1667,19 @@ export const ja: TranslationResources = {
title: "プロバイダーを追加",
search: "プロバイダーを検索",
noProviders: "プロバイダーが見つかりません",
clearSearch: "検索をクリア",
actions: {
add: "追加",
addProvider: "{{provider}} を追加",
adding: "追加中",
installed: "インストール済み",
cancel: "キャンセル",
installInstructions: "インストール手順",
installInstructionsFor: "{{provider}}のインストール手順",
},
types: {
acp: "ACP",
},
errors: {
unableToInstall: "プロバイダーをインストールできません",
},
+7
View File
@@ -1077,6 +1077,8 @@ export const ko: TranslationResources = {
},
thread: {
discussion: "토론 스레드",
resolved: "해결됨",
outdated: "오래됨",
},
errors: {
statusLoadFailed: "풀 리퀘스트 상태를 불러올 수 없습니다",
@@ -1658,14 +1660,19 @@ export const ko: TranslationResources = {
title: "프로바이더 추가",
search: "프로바이더 검색",
noProviders: "프로바이더를 찾을 수 없습니다",
clearSearch: "검색 지우기",
actions: {
add: "추가",
addProvider: "{{provider}} 추가",
adding: "추가 중",
installed: "설치됨",
cancel: "취소",
installInstructions: "설치 안내",
installInstructionsFor: "{{provider}} 설치 안내",
},
types: {
acp: "ACP",
},
errors: {
unableToInstall: "프로바이더를 설치할 수 없습니다",
},
@@ -27,7 +27,7 @@ export const pluginSettings = {
globalTitle: "تفعيل الإضافات",
globalHint: "مفتاح عام لكل الإضافات المضبوطة",
sourceLabel: "مصدر الإضافة",
sourcePlaceholder: "مجلد أو رابط Git أو حزمة npm",
sourcePlaceholder: "owner/slug, مجلد أو رابط Git أو حزمة npm",
docs: "الوثائق",
install: "تثبيت الإضافة",
installing: "جارٍ التثبيت…",
@@ -82,7 +82,7 @@ export const pluginSettings = {
globalTitle: "Activar plugins",
globalHint: "Interruptor global para todos los plugins configurados",
sourceLabel: "Fuente del plugin",
sourcePlaceholder: "Directorio, URL de Git o paquete npm",
sourcePlaceholder: "owner/slug, Directorio, URL de Git o paquete npm",
docs: "Documentación",
install: "Instalar plugin",
installing: "Instalando…",
@@ -137,7 +137,7 @@ export const pluginSettings = {
globalTitle: "Activer les plugins",
globalHint: "Interrupteur global pour tous les plugins configurés",
sourceLabel: "Source du plugin",
sourcePlaceholder: "Dossier, URL Git ou paquet npm",
sourcePlaceholder: "owner/slug, Dossier, URL Git ou paquet npm",
docs: "Documentation",
install: "Installer le plugin",
installing: "Installation…",
@@ -192,7 +192,7 @@ export const pluginSettings = {
globalTitle: "プラグインを有効化",
globalHint: "設定済みプラグインすべての共通スイッチ",
sourceLabel: "プラグインソース",
sourcePlaceholder: "ディレクトリ、Git URL、npm パッケージ",
sourcePlaceholder: "owner/slug, ディレクトリ、Git URL、npm パッケージ",
docs: "ドキュメント",
install: "プラグインをインストール",
installing: "インストール中…",
@@ -246,7 +246,7 @@ export const pluginSettings = {
globalTitle: "플러그인 활성화",
globalHint: "설정된 모든 플러그인의 전역 스위치",
sourceLabel: "플러그인 소스",
sourcePlaceholder: "디렉터리, Git URL 또는 npm 패키지",
sourcePlaceholder: "owner/slug, 디렉터리, Git URL 또는 npm 패키지",
docs: "문서",
install: "플러그인 설치",
installing: "설치 중…",
@@ -301,7 +301,7 @@ export const pluginSettings = {
globalTitle: "Ativar plugins",
globalHint: "Controle global para todos os plugins configurados",
sourceLabel: "Fonte do plugin",
sourcePlaceholder: "Diretório, URL Git ou pacote npm",
sourcePlaceholder: "owner/slug, Diretório, URL Git ou pacote npm",
docs: "Documentação",
install: "Instalar plugin",
installing: "Instalando…",
@@ -356,7 +356,7 @@ export const pluginSettings = {
globalTitle: "Включить плагины",
globalHint: "Общий переключатель для всех настроенных плагинов",
sourceLabel: "Источник плагина",
sourcePlaceholder: "Каталог, URL Git или пакет npm",
sourcePlaceholder: "owner/slug, Каталог, URL Git или пакет npm",
docs: "Документация",
install: "Установить плагин",
installing: "Установка…",
@@ -410,7 +410,7 @@ export const pluginSettings = {
globalTitle: "启用插件",
globalHint: "所有已配置插件的全局开关",
sourceLabel: "插件来源",
sourcePlaceholder: "目录、Git URL 或 npm 包",
sourcePlaceholder: "owner/slug, 目录、Git URL 或 npm 包",
docs: "文档",
install: "安装插件",
installing: "正在安装…",
+9 -2
View File
@@ -1084,15 +1084,17 @@ export const ptBR: TranslationResources = {
},
activity: {
commented: "Comentou",
approved: "Aprovado",
approved: "Aprovou",
requestedChanges: "Solicitou alterações",
reviewed: "Revisado",
reviewed: "Revisou",
},
time: {
justNow: "agora mesmo",
},
thread: {
discussion: "Tópico de discussão",
resolved: "Resolvido",
outdated: "Desatualizado",
},
errors: {
statusLoadFailed: "Não foi possível carregar o status da pull request",
@@ -1679,14 +1681,19 @@ export const ptBR: TranslationResources = {
title: "Adicionar provedor",
search: "Buscar provedores",
noProviders: "Nenhum provedor encontrado",
clearSearch: "Limpar pesquisa",
actions: {
add: "Adicionar",
addProvider: "Adicionar {{provider}}",
adding: "Adicionando",
installed: "Instalado",
cancel: "Cancelar",
installInstructions: "Instruções de instalação",
installInstructionsFor: "Instruções de instalação de {{provider}}",
},
types: {
acp: "ACP",
},
errors: {
unableToInstall: "Não foi possível instalar provedor",
},
+7
View File
@@ -1087,6 +1087,8 @@ export const ru: TranslationResources = {
},
thread: {
discussion: "Ветка обсуждения",
resolved: "Решено",
outdated: "Устарело",
},
errors: {
statusLoadFailed: "Не удалось загрузить статус PR",
@@ -1677,14 +1679,19 @@ export const ru: TranslationResources = {
title: "Добавить провайдера",
search: "Поиск провайдеров",
noProviders: "Провайдеры не найдены",
clearSearch: "Очистить поиск",
actions: {
add: "Добавить",
addProvider: "Добавить {{provider}}",
adding: "Добавление",
installed: "Установлено",
cancel: "Отмена",
installInstructions: "Инструкции по установке",
installInstructionsFor: "Инструкции по установке {{provider}}",
},
types: {
acp: "ACP",
},
errors: {
unableToInstall: "Не удалось установить провайдера",
},
+11 -4
View File
@@ -1046,22 +1046,24 @@ export const zhCN: TranslationResources = {
},
},
states: {
draft: "Draft",
merged: "已 merge",
draft: "草稿",
merged: "已合并",
closed: "已关闭",
open: "Open",
open: "开放",
},
activity: {
commented: "已评论",
approved: "已批准",
requestedChanges: "请求修改",
reviewed: "已 review",
reviewed: "已审查",
},
time: {
justNow: "刚刚",
},
thread: {
discussion: "讨论主题",
resolved: "已解决",
outdated: "已过时",
},
errors: {
statusLoadFailed: "无法加载 Pull Request 状态",
@@ -1631,14 +1633,19 @@ export const zhCN: TranslationResources = {
title: "添加 provider",
search: "搜索 providers",
noProviders: "未找到 providers",
clearSearch: "清除搜索",
actions: {
add: "添加",
addProvider: "添加 {{provider}}",
adding: "正在添加",
installed: "已安装",
cancel: "取消",
installInstructions: "安装说明",
installInstructionsFor: "{{provider}} 安装说明",
},
types: {
acp: "ACP",
},
errors: {
unableToInstall: "无法安装 provider",
},
@@ -3047,6 +3047,34 @@ describe("HostRuntimeStore", () => {
useSessionStore.getState().clearSession(host.serverId);
});
it("drains a queued message as a steer so a turn the agent just started is not interrupted", async () => {
const host = makeHost({ serverId: "srv_steer_queue_drain" });
const fakeClient = new FakeDaemonClient();
const store = new HostRuntimeStore({
deps: {
createClient: () => fakeClient as unknown as DaemonClient,
connectToDaemon: async () => ({
client: fakeClient as unknown as DaemonClient,
serverId: host.serverId,
hostname: null,
}),
getClientId: async () => "cid_steer_queue_drain",
},
});
const sessionStore = useSessionStore.getState();
sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1);
sessionStore.setQueuedMessages(
host.serverId,
new Map([["agent", [{ id: "queued", text: "next task", attachments: [] }]]]),
);
store.drainQueuedAgentMessage(host.serverId, "agent");
await fakeClient.waitForSentMessages(1);
expect(fakeClient.sentAgentMessages[0]?.[2]?.activeTurnBehavior).toBe("steer");
sessionStore.clearSession(host.serverId);
});
it("uses legacy GitHub attachments when draining a queue for an old daemon", async () => {
const host = makeHost({ serverId: "srv_legacy_queue_attachment" });
const fakeClient = new FakeDaemonClient();
+5
View File
@@ -2426,6 +2426,11 @@ export class HostRuntimeStore {
}),
encodeImages,
submission: createMessageSubmissionWriter(serverId),
// The queue drains when the agent goes idle, but the agent can start a turn of its own
// in that same moment (a wake-up after background work). Steering folds the message
// into that turn instead of interrupting it; with no turn running, the daemon starts
// a normal one.
activeTurnBehavior: "steer",
});
},
})
@@ -6,6 +6,7 @@ import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
import type { MutableDaemonConfig } from "@getpaseo/protocol/messages";
import { ToastApiProvider } from "@/contexts/toast-api-context";
const { theme, snapshotState, configState, patchConfigMock, openProviderSettingsMock } = vi.hoisted(
() => ({
@@ -281,6 +282,12 @@ vi.mock("@/utils/confirm-dialog", () => ({
import { ProvidersSection } from "./providers-section";
function unexpectedToast(): never {
throw new Error("Provider row interactions should not display a toast");
}
const toastApi = { show: unexpectedToast, copied: unexpectedToast, error: unexpectedToast };
const claudeEntry: ProviderSnapshotEntry = {
provider: "claude",
status: "ready",
@@ -366,7 +373,11 @@ describe("ProvidersSection", () => {
function render(): void {
act(() => {
root?.render(<ProvidersSection serverId="server-1" />);
root?.render(
<ToastApiProvider api={toastApi}>
<ProvidersSection serverId="server-1" />
</ToastApiProvider>,
);
});
}
@@ -11,6 +11,7 @@ import {
} from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useToast } from "@/contexts/toast-api-context";
import { settingsStyles } from "@/styles/settings";
import { useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useHostFeature } from "@/runtime/host-features";
@@ -19,6 +20,7 @@ import { useDaemonConfig } from "@/hooks/use-daemon-config";
import { buildProviderDefinitions } from "@/utils/provider-definitions";
import {
buildAcpProviderConfigPatch,
getAcpProviderCatalog,
type AcpProviderCatalogItem,
} from "@/hooks/use-acp-provider-catalog";
import { ProviderCatalogList } from "@/components/provider-catalog-list";
@@ -325,10 +327,12 @@ export interface ProvidersSectionProps {
export function ProvidersSection({ serverId }: ProvidersSectionProps) {
const { t } = useTranslation();
const toast = useToast();
const isConnected = useHostRuntimeIsConnected(serverId);
const supportsHopliteProvider = useHostFeature(serverId, "hopliteProvider");
const supportsProviderRemoval = useHostFeature(serverId, "providerRemoval");
const { entries, isLoading, refresh } = useProvidersSnapshot(serverId);
const { patchConfig } = useDaemonConfig(serverId);
const { config, patchConfig } = useDaemonConfig(serverId);
const openProviderSettings = useProviderSettingsStore((state) => state.open);
const [pendingProviderId, setPendingProviderId] = useState<string | null>(null);
const [removingProviderId, setRemovingProviderId] = useState<string | null>(null);
@@ -338,6 +342,19 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) {
const providerDefinitions = useMemo(() => buildProviderDefinitions(entries), [entries]);
const hasServer = serverId.length > 0;
/** Config owns derived providers and opt-in bundled catalog providers. */
const isConfigOwnedProvider = useCallback(
(providerId: string) =>
Boolean(
config?.providers?.[providerId] &&
(config.providers[providerId].extends ||
getAcpProviderCatalog().some(
(entry) => entry.id === providerId && entry.bundledProvider,
)),
),
[config],
);
const handleOpenProviderSettings = useCallback(
(providerId: string) => {
openProviderSettings({ serverId, provider: providerId });
@@ -399,18 +416,15 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) {
if (installingProviderId) return;
setInstallingProviderId(entry.id);
try {
await patchConfig(buildAcpProviderConfigPatch(entry));
await patchConfig(buildAcpProviderConfigPatch(entry, supportsHopliteProvider));
await refresh([entry.id]);
} catch (error) {
Alert.alert(
t("settings.providers.addErrorTitle"),
error instanceof Error ? error.message : String(error),
);
toast.error(error instanceof Error ? error.message : String(error));
} finally {
setInstallingProviderId((current) => (current === entry.id ? null : current));
}
},
[installingProviderId, patchConfig, refresh, t],
[installingProviderId, patchConfig, refresh, supportsHopliteProvider, toast],
);
return (
@@ -444,7 +458,11 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) {
enabled={entry.enabled ?? true}
isToggling={pendingProviderId === def.id}
isRemoving={removingProviderId === def.id}
canRemove={supportsProviderRemoval && entry.source === "custom"}
canRemove={
supportsProviderRemoval &&
entry.source === "custom" &&
isConfigOwnedProvider(def.id)
}
isFirst={index === 0}
onPress={handleOpenProviderSettings}
onToggleEnabled={handleToggleEnabled}
@@ -1,8 +1,11 @@
import { DaemonConnectionError } from "@getpaseo/client/internal/daemon-client";
import { afterEach, describe, expect, test } from "vitest";
import type { ProviderSubagentDescriptorPayload } from "@getpaseo/protocol/messages";
import {
observeProviderSubagentTimeline,
providerSubagentKey,
refreshProviderSubagents,
resyncProviderSubagents,
useProviderSubagentStore,
} from "./provider-store";
@@ -15,6 +18,7 @@ afterEach(() => {
descriptors: new Map(),
timelines: new Map(),
hiddenFromTrack: new Set(),
trackedParents: new Set(),
});
});
@@ -727,3 +731,96 @@ describe("projected child history", () => {
expect(current().lastSeq).toBe(2000);
});
});
describe("resyncing after the update feed resubscribes", () => {
function child(
id: string,
parentAgentId: string,
status: ProviderSubagentDescriptorPayload["status"],
): ProviderSubagentDescriptorPayload {
return {
id,
parentAgentId,
provider: "claude",
title: "Explore",
description: null,
status,
createdAt: "2026-10-07T10:00:00.000Z",
updatedAt: "2026-10-07T10:00:00.000Z",
toolCallId: id,
};
}
function fakeDaemon(lists: Record<string, ProviderSubagentDescriptorPayload[]>) {
const requested: string[] = [];
return {
requested,
lists,
client: {
listProviderSubagents: async (parentAgentId: string) => {
requested.push(parentAgentId);
return {
requestId: "list",
parentAgentId,
subagents: lists[parentAgentId] ?? [],
error: null,
};
},
},
};
}
const status = (parentAgentId: string, subagentId: string) =>
useProviderSubagentStore
.getState()
.descriptors.get(providerSubagentKey(SERVER_ID, parentAgentId, subagentId))?.status;
test("catches up on children that finished or started while updates were not delivered", async () => {
const daemon = fakeDaemon({ [PARENT_ID]: [child("child-1", PARENT_ID, "running")] });
await refreshProviderSubagents(daemon.client, SERVER_ID, PARENT_ID);
expect(status(PARENT_ID, "child-1")).toBe("running");
// The connection dropped: the daemon finished child-1 and ran child-2 without telling us.
daemon.lists[PARENT_ID] = [
child("child-1", PARENT_ID, "completed"),
child("child-2", PARENT_ID, "completed"),
];
await resyncProviderSubagents(daemon.client, SERVER_ID);
expect(status(PARENT_ID, "child-1")).toBe("completed");
expect(status(PARENT_ID, "child-2")).toBe("completed");
});
test("catches up on a parent whose first list failed while disconnected", async () => {
const daemon = fakeDaemon({});
const disconnected = {
listProviderSubagents: async () => {
throw new DaemonConnectionError("Transport not connected");
},
};
await expect(
refreshProviderSubagents(disconnected, SERVER_ID, PARENT_ID),
).rejects.toBeInstanceOf(DaemonConnectionError);
useProviderSubagentStore.getState().applyUpdate(SERVER_ID, {
kind: "upsert",
subagent: child("child-1", PARENT_ID, "running"),
});
// The connection dropped again: the daemon finished child-1 without telling us.
daemon.lists[PARENT_ID] = [child("child-1", PARENT_ID, "completed")];
await resyncProviderSubagents(daemon.client, SERVER_ID);
expect(status(PARENT_ID, "child-1")).toBe("completed");
});
test("lists again only the parents this server was showing", async () => {
const daemon = fakeDaemon({});
await refreshProviderSubagents(daemon.client, SERVER_ID, PARENT_ID);
await refreshProviderSubagents(daemon.client, "server-2", "other-parent");
daemon.requested.length = 0;
await resyncProviderSubagents(daemon.client, SERVER_ID);
expect(daemon.requested).toEqual([PARENT_ID]);
});
});
@@ -28,6 +28,9 @@ interface ProviderSubagentState {
descriptors: Map<string, ProviderSubagentDescriptorPayload>;
timelines: Map<string, ProviderSubagentTimelineState>;
hiddenFromTrack: Set<string>;
/** Parents whose list this client asked for, so a resubscribed update feed can catch them up. */
trackedParents: Set<string>;
trackParent(serverId: string, parentAgentId: string): void;
hideFromTrack(serverId: string, parentAgentId: string, subagentIds: readonly string[]): void;
replaceList(
serverId: string,
@@ -84,6 +87,7 @@ export function refreshProviderSubagents(
const pending = clientRequests.get(requestKey);
if (pending) return pending;
useProviderSubagentStore.getState().trackParent(serverId, parentAgentId);
const request = client
.listProviderSubagents(parentAgentId)
.then((payload) => {
@@ -101,6 +105,27 @@ function parentPrefix(serverId: string, parentAgentId: string): string {
return `${serverId}\0${parentAgentId}\0`;
}
/**
* Updates are not replayed across a reconnect, so a child that finished or started while the
* update feed was down stays stale until its parent is listed again. Call this once the feed has
* resubscribed, so every update after the list is delivered.
*/
export async function resyncProviderSubagents(
client: ProviderSubagentListClient,
serverId: string,
): Promise<void> {
const serverPrefix = `${serverId}\0`;
const parentAgentIds = [...useProviderSubagentStore.getState().trackedParents]
.filter((key) => key.startsWith(serverPrefix))
.map((key) => key.slice(serverPrefix.length, -1));
await Promise.all(
parentAgentIds.map((parentAgentId) =>
// A parent that was archived meanwhile has nothing left to catch up.
refreshProviderSubagents(client, serverId, parentAgentId).catch(() => undefined),
),
);
}
const EMPTY_TIMELINE: ProviderSubagentTimelineState = {
tail: [],
head: [],
@@ -147,6 +172,14 @@ export const useProviderSubagentStore = create<ProviderSubagentState>((set) => (
descriptors: new Map(),
timelines: new Map(),
hiddenFromTrack: new Set(),
trackedParents: new Set(),
trackParent(serverId, parentAgentId) {
set((state) => {
const prefix = parentPrefix(serverId, parentAgentId);
if (state.trackedParents.has(prefix)) return state;
return { trackedParents: new Set(state.trackedParents).add(prefix) };
});
},
hideFromTrack(serverId, parentAgentId, subagentIds) {
set((state) => {
const hiddenFromTrack = new Set(state.hiddenFromTrack);
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.11.0-beta.5",
"version": "0.11.3",
"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.5",
"@getpaseo/protocol": "0.11.0-beta.5",
"@getpaseo/server": "0.11.0-beta.5",
"@getpaseo/client": "0.11.3",
"@getpaseo/protocol": "0.11.3",
"@getpaseo/server": "0.11.3",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -358,14 +358,21 @@ test.skipIf(process.platform === "win32").each([["start"], ["daemon", "run"]])(
stdio: "ignore",
});
const exited = new Promise((resolve) => child!.once("exit", resolve));
// The lock file exists before its contents identify the supervisor.
let supervisorPid: number | undefined;
await expect
.poll(async () => existsSync(path.join(home, "paseo.pid")), { timeout: 10_000 })
.toBe(true);
const lock = JSON.parse(await readFile(path.join(home, "paseo.pid"), "utf8"));
.poll(
async () => {
supervisorPid = (await readDaemonInstance(home))?.pid;
return supervisorPid;
},
{ timeout: 10_000 },
)
.toBeTypeOf("number");
await f.ok(["status", "--home", home]);
child.kill("SIGINT");
await exited;
expect(() => process.kill(lock.pid, 0)).toThrow();
expect(() => process.kill(supervisorPid!, 0)).toThrow();
expect((await f.ok(["status", "--home", home])).localDaemon).toBe("stopped");
} finally {
child?.kill("SIGTERM");
@@ -255,12 +255,12 @@ describe("plugin management commands", () => {
const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
const command = createPluginCommand();
await command.parseAsync(["install", "owner/monorepo", "--path", "plugins/review"], {
await command.parseAsync(["install", "git:owner/monorepo", "--path", "plugins/review"], {
from: "user",
});
expect(installPluginSource).toHaveBeenCalledWith({
source: "owner/monorepo:plugins/review",
source: "git:owner/monorepo:plugins/review",
});
stderr.mockRestore();
});
+19 -3
View File
@@ -1,9 +1,12 @@
import path from "node:path";
import { expandUserPath } from "../../classify.js";
import { createInterface } from "node:readline/promises";
import { reviewPluginUpdates, type UpdateOutcome } from "./update.js";
import { Command } from "commander";
import type { PluginListItem, PluginLogEntry } from "@getpaseo/protocol/messages";
import {
formatPluginSourceReference,
parsePluginSourceReference,
formatPluginIdentity,
} from "@getpaseo/protocol/plugin-source-reference";
import type { CommandOptions, ListResult, OutputSchema, SingleResult } from "../../output/index.js";
@@ -125,7 +128,18 @@ export async function runPluginInstallCommand(
process.stderr.write(
"Trusting plugin code: server code and preparation commands run unsandboxed on the daemon host; client code runs inside Paseo. Dependencies and future updates are part of the codebase you trust.\n",
);
const sourceReference = formatPluginSourceReference(source, options.path);
const reference = parsePluginSourceReference(source);
let resolvedSource = source;
if (reference.kind === "directory") {
const directory = expandUserPath(reference.source);
// An absolute path can target a daemon on a different operating system.
const absoluteDirectory =
path.posix.isAbsolute(directory) || path.win32.isAbsolute(directory)
? directory
: path.resolve(directory);
resolvedSource = formatPluginSourceReference(absoluteDirectory, reference.pluginPath);
}
const sourceReference = formatPluginSourceReference(resolvedSource, options.path);
const data = await withPluginSourceClient(options.daemonTarget, (client) =>
client.installPluginSource({
source: sourceReference,
@@ -215,10 +229,12 @@ export function createPluginCommand(): Command {
plugin
.command("install")
.alias("add")
.description("Trust and install a plugin from a directory, Git repository, or npm package")
.description(
"Trust and install a plugin from a registry, directory, Git repository, or npm package",
)
.argument(
"<source>",
"Host directory, GitHub owner/repo, git: or npm: source, optionally followed by :plugin/path",
"Registry owner/slug or host/owner/slug, host directory, explicit git:owner/repo or Git URL, or npm: source",
)
.option("--id <id>", "Runtime plugin ID (defaults to paseo-plugin.json id)")
.option("--ref <ref>", "Git branch, tag, or commit")
@@ -1,4 +1,4 @@
import { execFile } from "node:child_process";
import { exec, execFile } from "node:child_process";
import { mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
import { createRequire } from "node:module";
import path from "node:path";
@@ -9,6 +9,7 @@ import { scaffoldPluginDirectory } from "./scaffold.js";
const directories: string[] = [];
const execFileAsync = promisify(execFile);
const execAsync = promisify(exec);
const tscPath = createRequire(import.meta.url).resolve("typescript/bin/tsc");
async function typecheckPlugin(directory: string): Promise<void> {
@@ -28,6 +29,47 @@ afterEach(async () => {
});
describe("plugin scaffold", () => {
it("creates an author overview beside the manifest", async () => {
const parent = await mkdtemp(path.join(tmpdir(), "paseo-plugin-scaffold-"));
directories.push(parent);
const directory = path.join(parent, "hello-plugin");
await scaffoldPluginDirectory(directory);
const overview = await readFile(path.join(directory, "OVERVIEW.md"), "utf8");
const comments = overview.match(/<!--[\s\S]*?-->/g) ?? [];
expect(comments).toHaveLength(1);
expect(overview.replace(/<!--[\s\S]*?-->/g, "").trim()).toBe("");
expect(overview).toContain("required to list");
expect(overview).toContain("README");
expect(overview).toContain("choosing a plugin in Paseo");
expect(overview).toContain("Length follows complexity");
expect(overview).toContain("1. Describe what your plugin is and does");
expect(overview).toContain("2. Explain how it works only when it is not obvious");
expect(overview).toContain("3. Explain setup when needed");
expect(overview).toContain("4. Explain capabilities and settings");
expect(overview).toContain("permissions");
expect(overview).toContain("known limits");
expect(overview).not.toContain("This plugin entry was imported");
const packageJson = JSON.parse(await readFile(path.join(directory, "package.json"), "utf8"));
expect(packageJson.files).toContain("OVERVIEW.md");
expect(await readdir(directory)).toContain("paseo-plugin.json");
});
it("includes the author overview in the packed npm artifact", async () => {
const parent = await mkdtemp(path.join(tmpdir(), "paseo-plugin-scaffold-"));
directories.push(parent);
const directory = path.join(parent, "packed-plugin");
await scaffoldPluginDirectory(directory);
const { stdout } = await execAsync("npm pack --dry-run --ignore-scripts --json", {
cwd: directory,
});
const [artifact] = JSON.parse(stdout) as { files: { path: string }[] }[];
expect(artifact.files.map((file) => file.path)).toEqual(
expect.arrayContaining(["OVERVIEW.md", "paseo-plugin.json"]),
);
});
it(
"creates a standalone split-runtime project that typechecks",
{ timeout: 15_000 },
@@ -51,6 +93,8 @@ describe("plugin scaffold", () => {
expect(JSON.parse(await readFile(path.join(directory, "paseo-plugin.json"), "utf8"))).toEqual(
{
id: "hello-plugin",
$comment: expect.stringContaining('"icon": "assets/icon.png"'),
media: [],
requirements: { paseo: `>=${cliPackageJson.version}` },
},
);
@@ -60,6 +104,7 @@ describe("plugin scaffold", () => {
version: "0.0.0",
files: [
"paseo-plugin.json",
"OVERVIEW.md",
"index.client.ts",
"index.client.tsx",
"index.server.ts",
+41 -1
View File
@@ -3,6 +3,34 @@ import path from "node:path";
import { PluginIdSchema } from "@getpaseo/protocol/messages";
import { resolveCliVersion } from "../../version.js";
const OVERVIEW = `<!--
OVERVIEW.md is required to list your plugin in the registry. Keep it beside
paseo-plugin.json in your repository at the pinned commit and in your npm package.
Replace this comment with useful facts before publishing.
This overview is for people choosing a plugin in Paseo, on the plugin page under
its install command. README assumes a GitHub audience and includes technical
details, installation instructions, and badges. Long, AI-generated READMEs leave
people reading past that material to understand what the plugin does.
Write these parts in order, including only what helps someone decide to install:
1. Describe what your plugin is and does in plain terms first.
2. Explain how it works only when it is not obvious.
3. Explain setup when needed: settings, accounts, tokens, providers, tools, or
other plugins. Include applicable daemon version and operating system
requirements. Setup guidance is allowed; installation instructions are not.
4. Explain capabilities and settings worth understanding, what each option does,
what the plugin reads or sends and where, permissions, and known limits.
Length follows complexity; a theme needs one paragraph. Choose headings only
when they help. Use sentence case and plain factual language, with no em dashes.
Omit installation commands, badges, changelog, contributing or license sections,
marketing, and unsupported claims. Avoid implementation filler such as empty
cleanup functions, theme-token field lists, or lists of absent features.
-->
`;
const TSCONFIG = {
compilerOptions: {
target: "ES2020",
@@ -155,6 +183,7 @@ export async function scaffoldPluginDirectory(
version: "0.0.0",
files: [
"paseo-plugin.json",
"OVERVIEW.md",
"index.client.ts",
"index.client.tsx",
"index.server.ts",
@@ -177,8 +206,19 @@ export async function scaffoldPluginDirectory(
const files = new Map<string, string>([
[
"paseo-plugin.json",
`${JSON.stringify({ id, requirements: { paseo: `>=${version}` } }, null, 2)}\n`,
`${JSON.stringify(
{
id,
$comment:
'Add "name": "My plugin" to set a display name. Add "icon": "assets/icon.png" for a PNG inside the package. Add image or video paths (relative to this manifest) or HTTPS URLs to media. Include local assets in package.json files.',
media: [],
requirements: { paseo: `>=${version}` },
},
null,
2,
)}\n`,
],
["OVERVIEW.md", OVERVIEW],
["package.json", `${JSON.stringify(packageJson, null, 2)}\n`],
["tsconfig.json", `${JSON.stringify(TSCONFIG, null, 2)}\n`],
["index.client.tsx", CLIENT_ENTRY],
+8 -3
View File
@@ -122,10 +122,15 @@ const EXPECTED_CLAUDE_MODELS = [
model: "Opus 4.6",
descriptionFragment: "Most capable",
},
{
id: "claude-haiku-5-5",
model: "Haiku 5.5",
descriptionFragment: "Fastest",
},
{
id: "claude-haiku-4-5",
model: "Haiku 4.5",
descriptionFragment: "Fastest",
descriptionFragment: "Previous release",
},
] as const;
@@ -435,8 +440,8 @@ try {
"--quiet should print the same ordered model IDs returned by --json",
);
assert(
claudeModelsFromJson.some((m) => m.id === "claude-sonnet-5-5"),
"captured --json output should include the current Claude everyday model id",
claudeModelsFromJson.some((m) => m.id === "claude-haiku-5-5"),
"captured --json output should include the current Claude quick-answer model id",
);
console.log("✓ provider models --quiet outputs model IDs only\n");
}
@@ -8,11 +8,13 @@ import {
} from "../../../../scripts/test-support/npm-registry.mjs";
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { promisify } from "node:util";
import { once } from "node:events";
import { WebSocket, WebSocketServer } from "ws";
import { connectToDaemon } from "../../src/utils/client.ts";
import { createE2ETestContext } from "../helpers/test-daemon.ts";
@@ -45,6 +47,81 @@ async function main(): Promise<void> {
);
await writeFile(path.join(directory, "index.server.ts"), pluginSource);
// Observe real CLI requests while forwarding them to the isolated daemon.
const proxy = new WebSocketServer({ host: "127.0.0.1", port: 0 });
const sources: string[] = [];
proxy.on("connection", (socket, request) => {
const upstream = new WebSocket(`ws://127.0.0.1:${context.port}${request.url}`, {
headers: { authorization: request.headers.authorization ?? "" },
});
const ready = once(upstream, "open");
socket.on("message", async (data, isBinary) => {
const message = JSON.parse(data.toString());
if (
message.type === "session" &&
message.message.type === "plugin.source.install.request"
) {
sources.push(message.message.source);
}
await ready;
upstream.send(data, { binary: isBinary });
});
upstream.on("message", (data, isBinary) => socket.send(data, { binary: isBinary }));
socket.on("close", () => upstream.close());
upstream.on("close", () => socket.close());
});
await once(proxy, "listening");
const address = proxy.address();
assert.ok(address && typeof address !== "string");
const sibling = path.join(context.workDir, "x");
const caller = path.join(context.workDir, "caller");
await mkdir(caller);
await mkdir(path.join(sibling, "sub"), { recursive: true });
for (const target of [caller, path.join(sibling, "sub"), context.paseoHome]) {
await writeFile(
path.join(target, "paseo-plugin.json"),
JSON.stringify({
id: "relative-cli",
requirements: { paseo: `>=${resolveCliVersion()}` },
}),
);
await writeFile(path.join(target, "index.server.ts"), pluginSource);
}
assert.notEqual(caller, process.cwd()); // The daemon inherits the test runner's cwd.
try {
for (const [source, expectedSource, expectedDirectory, extraArgs] of [
[".", caller, caller, []],
["../x:sub", `${sibling}:sub`, path.join(sibling, "sub"), []],
["../x", `${sibling}:sub`, path.join(sibling, "sub"), ["--path", "sub"]],
["~", context.paseoHome, context.paseoHome, []],
["~/.:.", `${context.paseoHome}:.`, context.paseoHome, []],
] as const) {
sources.length = 0;
const result = await context.paseo(
["plugin", "add", source, ...extraArgs, "--host", `127.0.0.1:${address.port}`, "--json"],
{ cwd: caller },
);
assert.deepEqual(sources, [expectedSource]);
assert.equal(result.exitCode, 0, result.stderr);
assert.equal(JSON.parse(result.stdout).path, expectedDirectory);
const removed = await context.paseo(["plugin", "remove", "relative-cli", "--json"]);
assert.equal(removed.exitCode, 0, removed.stderr);
}
// An absolute path belongs to the daemon's platform, which can differ from the CLI's.
const windowsSource = `C:\\${path.basename(context.workDir)}\\missing:sub`;
sources.length = 0;
const missing = await context.paseo(
["plugin", "add", windowsSource, "--host", `127.0.0.1:${address.port}`, "--json"],
{ cwd: caller },
);
assert.deepEqual(sources, [windowsSource]);
assert.equal(missing.exitCode, 1);
assert.match(missing.stderr, /Plugin directory does not exist/);
} finally {
for (const socket of proxy.clients) socket.close();
await promisify(proxy.close.bind(proxy))();
}
const install = await context.paseo(["plugin", "install", directory, "--json"]);
assert.equal(install.exitCode, 0, install.stderr);
assert.equal(JSON.parse(install.stdout).id, "cli-e2e");
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/client",
"version": "0.11.0-beta.5",
"version": "0.11.3",
"description": "Paseo client SDK package",
"files": [
"dist",
@@ -39,8 +39,8 @@
"test": "vitest run"
},
"dependencies": {
"@getpaseo/protocol": "0.11.0-beta.5",
"@getpaseo/relay": "0.11.0-beta.5",
"@getpaseo/protocol": "0.11.3",
"@getpaseo/relay": "0.11.3",
"zod": "^4.4.3"
},
"devDependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.11.0-beta.5",
"version": "0.11.3",
"private": true,
"description": "Paseo desktop app (Electron wrapper)",
"homepage": "https://paseo.sh",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/expo-two-way-audio",
"version": "0.11.0-beta.5",
"version": "0.11.3",
"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.5",
"version": "0.11.3",
"files": [
"dist",
"!dist/**/*.map",
@@ -144,6 +144,19 @@ const title = "Hello";
);
});
it("keeps an Astro expression open through nested template strings", () => {
const code = "<p>{`a ${`}`} b`}</p>";
const tokens = highlightCode(code, "Nested.astro").flat();
expect(tokens).toEqual(
expect.arrayContaining([
{ text: "`}`", style: "string" },
{ text: " b`", style: "string" },
]),
);
});
it("highlights Vue SFCs across script, template, and style", () => {
const code = [
'<script lang="ts">',
+2 -78
View File
@@ -14,6 +14,7 @@ import {
import { parser as cssParser } from "@lezer/css";
import { parser as htmlParser } from "@lezer/html";
import { parser as jsParser } from "@lezer/javascript";
import { findClosingBrace } from "../js-scanner.js";
interface Range {
from: number;
@@ -23,88 +24,11 @@ interface Range {
const jsxParser = jsParser.configure({ dialect: "ts jsx" });
const typescriptParser = jsParser.configure({ dialect: "ts" });
function isSpace(code: number): boolean {
return code === 32 || code === 9 || code === 10 || code === 13;
}
function isRegexStart(text: string, position: number): boolean {
let previous = position - 1;
while (previous >= 0 && isSpace(text.charCodeAt(previous))) previous--;
if (previous < 0) return true;
const character = text[previous];
if (/[)\]}<"'`\d]/.test(character)) return false;
const code = text.charCodeAt(previous);
if (code === 62) return previous > 0 && text.charCodeAt(previous - 1) === 61;
if (!/[A-Za-z_$]/.test(character)) return true;
let start = previous;
while (start >= 0 && /[A-Za-z0-9_$]/.test(text[start])) start--;
const keyword = text.slice(start + 1, previous + 1);
return /^(return|typeof|instanceof|in|of|new|void|delete|yield|await|case|do|else|throw|extends|assert|with)$/.test(
keyword,
);
}
function skipQuotedText(text: string, opening: number): number {
const quote = text.charCodeAt(opening);
for (let position = opening + 1; position < text.length; position++) {
const code = text.charCodeAt(position);
if (code === 92) position++;
else if (code === quote) return position;
}
return text.length - 1;
}
function skipLineComment(text: string, opening: number): number {
const newline = text.indexOf("\n", opening + 2);
return newline >= 0 ? newline : text.length - 1;
}
function skipBlockComment(text: string, opening: number): number {
const closing = text.indexOf("*/", opening + 2);
return closing >= 0 ? closing + 1 : text.length - 1;
}
function skipRegex(text: string, opening: number): number {
let isInCharacterClass = false;
for (let position = opening + 1; position < text.length; position++) {
const code = text.charCodeAt(position);
if (code === 10 || code === 13) return position;
if (code === 92) position++;
else if (isInCharacterClass && code === 93) isInCharacterClass = false;
else if (!isInCharacterClass && code === 91) isInCharacterClass = true;
else if (!isInCharacterClass && code === 47) return position;
}
return text.length - 1;
}
function findClosingBrace(text: string, opening: number): number {
let depth = 0;
for (let position = opening; position < text.length; position++) {
const code = text.charCodeAt(position);
if (code === 47 && text.charCodeAt(position + 1) === 47) {
position = skipLineComment(text, position);
} else if (code === 47 && text.charCodeAt(position + 1) === 42) {
position = skipBlockComment(text, position);
} else if (code === 47 && isRegexStart(text, position)) {
position = skipRegex(text, position);
} else if (code === 34 || code === 39 || code === 96) {
position = skipQuotedText(text, position);
} else if (code === 123) {
depth++;
} else if (code === 125 && --depth === 0) {
return position;
}
}
return -1;
}
function findExpressions(text: string): Range[] {
const ranges: Range[] = [];
for (let position = 0; position < text.length; position++) {
if (text.charCodeAt(position) !== 123) continue;
const closing = findClosingBrace(text, position);
const closing = findClosingBrace(text, position + 1);
if (closing < 0) break;
ranges.push({ from: position, to: closing });
position = closing;
+100
View File
@@ -0,0 +1,100 @@
// Scans JavaScript embedded in a template (Astro `{ }` expressions, Vue `{{ }}`
// interpolations) for the brace that closes it, without parsing the code.
function isSpace(code: number): boolean {
return code === 32 || code === 9 || code === 10 || code === 13;
}
function isRegexStart(text: string, position: number): boolean {
let previous = position - 1;
while (previous >= 0 && isSpace(text.charCodeAt(previous))) previous--;
if (previous < 0) return true;
const character = text[previous];
if (/[)\]}<"'`\d]/.test(character)) return false;
const code = text.charCodeAt(previous);
if (code === 62) return previous > 0 && text.charCodeAt(previous - 1) === 61;
if (!/[A-Za-z_$]/.test(character)) return true;
let start = previous;
while (start >= 0 && /[A-Za-z0-9_$]/.test(text[start])) start--;
const keyword = text.slice(start + 1, previous + 1);
return /^(return|typeof|instanceof|in|of|new|void|delete|yield|await|case|do|else|throw|extends|assert|with)$/.test(
keyword,
);
}
// Template strings nested deeper than this are treated as running to the end.
const MAX_TEMPLATE_NESTING = 32;
function skipQuotedText(text: string, opening: number, nesting = 0): number {
const quote = text.charCodeAt(opening);
for (let position = opening + 1; position < text.length; position++) {
const code = text.charCodeAt(position);
if (code === 92) position++;
else if (code === quote) return position;
else if (quote === 96 && code === 36 && text.charCodeAt(position + 1) === 123) {
position = skipTemplateSubstitution(text, position + 2, nesting);
}
}
return text.length - 1;
}
function skipTemplateSubstitution(text: string, start: number, nesting: number): number {
if (nesting >= MAX_TEMPLATE_NESTING) return text.length - 1;
const closing = findClosingBrace(text, start, () => true, nesting + 1);
return closing >= 0 ? closing : text.length - 1;
}
function skipLineComment(text: string, opening: number): number {
const newline = text.indexOf("\n", opening + 2);
return newline >= 0 ? newline : text.length - 1;
}
function skipBlockComment(text: string, opening: number): number {
const closing = text.indexOf("*/", opening + 2);
return closing >= 0 ? closing + 1 : text.length - 1;
}
function skipRegex(text: string, opening: number): number {
let isInCharacterClass = false;
for (let position = opening + 1; position < text.length; position++) {
const code = text.charCodeAt(position);
if (code === 10 || code === 13) return position;
if (code === 92) position++;
else if (isInCharacterClass && code === 93) isInCharacterClass = false;
else if (!isInCharacterClass && code === 91) isInCharacterClass = true;
else if (!isInCharacterClass && code === 47) return position;
}
return text.length - 1;
}
// Finds the first `}` outside nested braces, strings, comments, and regexes
// for which `isEnd` holds.
export function findClosingBrace(
text: string,
start: number,
isEnd: (position: number) => boolean = () => true,
nesting = 0,
): number {
let depth = 0;
for (let position = start; position < text.length; position++) {
const code = text.charCodeAt(position);
if (code === 47 && text.charCodeAt(position + 1) === 47) {
position = skipLineComment(text, position);
} else if (code === 47 && text.charCodeAt(position + 1) === 42) {
position = skipBlockComment(text, position);
} else if (code === 47 && isRegexStart(text, position)) {
position = skipRegex(text, position);
} else if (code === 34 || code === 39 || code === 96) {
position = skipQuotedText(text, position, nesting);
} else if (code === 123) {
depth++;
} else if (code === 125 && depth === 0) {
if (isEnd(position)) return position;
} else if (code === 125) {
depth--;
}
}
return -1;
}
+1 -98
View File
@@ -8,6 +8,7 @@ import {
import { parser as cssParser } from "@lezer/css";
import { parser as htmlParser } from "@lezer/html";
import { parser as jsParser } from "@lezer/javascript";
import { findClosingBrace } from "../js-scanner.js";
interface Range {
from: number;
@@ -17,104 +18,6 @@ interface Range {
const typescriptParser = jsParser.configure({ dialect: "ts" });
const expressionParser = jsParser.configure({ top: "SingleExpression" });
function isSpace(code: number): boolean {
return code === 32 || code === 9 || code === 10 || code === 13;
}
function isRegexStart(text: string, position: number): boolean {
let previous = position - 1;
while (previous >= 0 && isSpace(text.charCodeAt(previous))) previous--;
if (previous < 0) return true;
const character = text[previous];
if (/[)\]}<"'`\d]/.test(character)) return false;
const code = text.charCodeAt(previous);
if (code === 62) return previous > 0 && text.charCodeAt(previous - 1) === 61;
if (!/[A-Za-z_$]/.test(character)) return true;
let start = previous;
while (start >= 0 && /[A-Za-z0-9_$]/.test(text[start])) start--;
const keyword = text.slice(start + 1, previous + 1);
return /^(return|typeof|instanceof|in|of|new|void|delete|yield|await|case|do|else|throw|extends|assert|with)$/.test(
keyword,
);
}
// Template strings nested deeper than this are treated as running to the end.
const MAX_TEMPLATE_NESTING = 32;
function skipQuotedText(text: string, opening: number, nesting = 0): number {
const quote = text.charCodeAt(opening);
for (let position = opening + 1; position < text.length; position++) {
const code = text.charCodeAt(position);
if (code === 92) position++;
else if (code === quote) return position;
else if (quote === 96 && code === 36 && text.charCodeAt(position + 1) === 123) {
position = skipTemplateSubstitution(text, position + 2, nesting);
}
}
return text.length - 1;
}
function skipTemplateSubstitution(text: string, start: number, nesting: number): number {
if (nesting >= MAX_TEMPLATE_NESTING) return text.length - 1;
const closing = findClosingBrace(text, start, () => true, nesting + 1);
return closing >= 0 ? closing : text.length - 1;
}
function skipLineComment(text: string, opening: number): number {
const newline = text.indexOf("\n", opening + 2);
return newline >= 0 ? newline : text.length - 1;
}
function skipBlockComment(text: string, opening: number): number {
const closing = text.indexOf("*/", opening + 2);
return closing >= 0 ? closing + 1 : text.length - 1;
}
function skipRegex(text: string, opening: number): number {
let isInCharacterClass = false;
for (let position = opening + 1; position < text.length; position++) {
const code = text.charCodeAt(position);
if (code === 10 || code === 13) return position;
if (code === 92) position++;
else if (isInCharacterClass && code === 93) isInCharacterClass = false;
else if (!isInCharacterClass && code === 91) isInCharacterClass = true;
else if (!isInCharacterClass && code === 47) return position;
}
return text.length - 1;
}
// Finds the first `}` outside nested braces, strings, comments, and regexes
// for which `isEnd` holds.
function findClosingBrace(
text: string,
start: number,
isEnd: (position: number) => boolean,
nesting = 0,
): number {
let depth = 0;
for (let position = start; position < text.length; position++) {
const code = text.charCodeAt(position);
if (code === 47 && text.charCodeAt(position + 1) === 47) {
position = skipLineComment(text, position);
} else if (code === 47 && text.charCodeAt(position + 1) === 42) {
position = skipBlockComment(text, position);
} else if (code === 47 && isRegexStart(text, position)) {
position = skipRegex(text, position);
} else if (code === 34 || code === 39 || code === 96) {
position = skipQuotedText(text, position, nesting);
} else if (code === 123) {
depth++;
} else if (code === 125 && depth === 0) {
if (isEnd(position)) return position;
} else if (code === 125) {
depth--;
}
}
return -1;
}
function findInterpolationEnd(text: string, start: number): number {
return findClosingBrace(text, start, (position) => text.charCodeAt(position + 1) === 125);
}
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/plugin",
"version": "0.11.0-beta.5",
"version": "0.11.3",
"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.5",
"@getpaseo/protocol": "0.11.0-beta.5",
"@getpaseo/client": "0.11.3",
"@getpaseo/protocol": "0.11.3",
"@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.5",
"@getpaseo/protocol": "0.11.0-beta.5",
"@getpaseo/client": "0.11.3",
"@getpaseo/protocol": "0.11.3",
"react": "~19.1.0",
"react-native": ">=0.81.5",
"zod": "^4.4.3"
@@ -359,6 +359,8 @@ class AcpRuntime {
private emit: (event: ProviderEvent) => void;
private readonly messages = new Map<string, string>();
private readonly toolCalls = new Map<string, AcpToolCallSnapshot>();
// Running tool rows published by transformers; the turn's terminal closes any left open.
private readonly vendorToolCalls = new Map<string, ToolCallTimelineItem>();
private readonly pendingCompactions = new Set<string>();
private readonly permissions = new Map<
string,
@@ -595,10 +597,13 @@ class AcpRuntime {
turnId,
state: "started",
});
// The SDK dispatches notifications without awaiting them, so the prompt response can resolve
// while earlier session updates are still queued. Apply them before the turn ends.
const settled = this.call(
this.connection.prompt({ sessionId: this.nativeSessionId, prompt }),
).then(
(response): void => {
async (response): Promise<void> => {
await this.drainNotifications();
const state = response.stopReason === "cancelled" ? "canceled" : "completed";
this.fallbackChunkIds.clear();
this.terminalizeTransientItems(state);
@@ -610,7 +615,8 @@ class AcpRuntime {
});
return undefined;
},
(error): void => {
async (error): Promise<void> => {
await this.drainNotifications();
this.fallbackChunkIds.clear();
this.terminalizeTransientItems("failed");
this.emit({
@@ -1027,9 +1033,17 @@ class AcpRuntime {
this.emit({
type: "timeline.item",
sessionId: this.options.boundarySessionId,
item: toolTimelineItem(completed),
item: terminalToolItem(toolTimelineItem(snapshot), state),
});
}
for (const item of this.vendorToolCalls.values()) {
this.emit({
type: "timeline.item",
sessionId: this.options.boundarySessionId,
item: terminalToolItem(item, state),
});
}
this.vendorToolCalls.clear();
for (const id of this.pendingCompactions) {
this.emit({
type: "timeline.item",
@@ -1076,6 +1090,10 @@ class AcpRuntime {
if (this.configTransaction) this.stagedTransformerConfig = update.config;
else this.emitConfigSnapshot(update.config);
} else if (update.type === "timeline") {
if (update.item.type === "tool_call") {
if (update.item.status === "running") this.vendorToolCalls.set(update.item.id, update.item);
else this.vendorToolCalls.delete(update.item.id);
}
this.emit({
type: "timeline.item",
sessionId: this.options.boundarySessionId,
@@ -1383,7 +1401,9 @@ function configValues(
return Object.fromEntries(options.map((option) => [option.id, option.currentValue]));
}
function toolTimelineItem(snapshot: AcpToolCallSnapshot): ProviderTimelineItem {
type ToolCallTimelineItem = Extract<ProviderTimelineItem, { type: "tool_call" }>;
function toolTimelineItem(snapshot: AcpToolCallSnapshot): ToolCallTimelineItem {
const input = jsonRecord(snapshot.input);
const isEdit = snapshot.kind === "edit" || snapshot.name?.toLowerCase().includes("edit") === true;
const detail = isEdit
@@ -1411,6 +1431,18 @@ function toolTimelineItem(snapshot: AcpToolCallSnapshot): ProviderTimelineItem {
return { ...base, status, error: null };
}
// A row the turn left open ends with the turn. The wire contract requires a non-null error on a
// failed tool call, so a canceled turn cancels its rows instead of failing them.
function terminalToolItem(
item: ToolCallTimelineItem,
state: "completed" | "failed" | "canceled",
): ToolCallTimelineItem {
if (state === "failed") {
return { ...item, status: "failed", error: item.error ?? { message: "Turn failed" } };
}
return { ...item, status: state, error: null };
}
function jsonValue(value: unknown) {
return z.json().parse(value);
}
+140
View File
@@ -619,6 +619,146 @@ lines.on("line", (line) => {
await connection.close();
});
it.each([
["end_turn", "completed", "completed"],
["cancelled", "canceled", "canceled"],
] as const)(
"closes a transformer's running tool row when the turn ends with %s",
async (stopReason, turnState, rowStatus) => {
const harness = connectorHarness({
handleMessage(instance, message) {
if (!("method" in message) || !("id" in message)) return false;
if (message.method !== "session/prompt") return false;
instance.notify("vendor/phase", { id: "phase-1", label: "Preparing" });
instance.respond(message as AcpRequestMessage, { stopReason });
return true;
},
});
const registration = runAcpProvider({
id: "vendor-row-acp",
label: "Vendor row ACP",
connector: harness.connector,
transformers: [
{
notification(notification) {
if (notification.method !== "vendor/phase") return null;
return {
type: "timeline",
item: {
type: "tool_call",
id: "phase-1",
callId: "phase-1",
name: "Vendor phase",
detail: { type: "plain_text", label: "Preparing" },
status: "running",
error: null,
},
};
},
},
],
});
const connection = await registration.connect({
versions: [1],
capabilities: ["prompt.message"],
});
const events: ProviderEvent[] = [];
connection.onEvent((event) => events.push(event));
await connection.send(openInput());
await waitForEvent(events, (event) => event.type === "session.ready");
await connection.send({
type: "session.prompt",
sessionId: "session-1",
prompt: {
clientMessageId: "first",
delivery: "auto",
input: { type: "message", content: [{ type: "text", text: "first" }] },
},
});
await waitForEvent(
events,
(event) => event.type === "session.turn" && event.state === turnState,
);
const rows = events.flatMap((event) =>
event.type === "timeline.item" && event.item.id === "phase-1" ? [event.item] : [],
);
expect(rows.map((row) => row.type === "tool_call" && row.status)).toEqual([
"running",
rowStatus,
]);
const closed = events.findIndex(
(event) =>
event.type === "timeline.item" &&
event.item.id === "phase-1" &&
event.item.type === "tool_call" &&
event.item.status === rowStatus,
);
const terminal = events.findIndex(
(event) => event.type === "session.turn" && event.state === turnState,
);
expect(closed).toBeLessThan(terminal);
// The wire contract only accepts a failed tool call with a non-null error.
expect(rows.at(-1)).toMatchObject({ error: null });
await connection.close();
},
);
it("cancels an in-progress ACP tool call with the canceled turn instead of failing it without an error", async () => {
const harness = connectorHarness({
handleMessage(instance, message) {
if (!("method" in message) || !("id" in message)) return false;
if (message.method !== "session/prompt") return false;
instance.notify("session/update", {
sessionId: "connector-session",
update: {
sessionUpdate: "tool_call",
toolCallId: "tool-1",
title: "Run tests",
kind: "execute",
status: "in_progress",
},
});
instance.respond(message as AcpRequestMessage, { stopReason: "cancelled" });
return true;
},
});
const registration = runAcpProvider({
id: "cancel-tool-acp",
label: "Cancel tool ACP",
connector: harness.connector,
});
const connection = await registration.connect({
versions: [1],
capabilities: ["prompt.message"],
});
const events: ProviderEvent[] = [];
connection.onEvent((event) => events.push(event));
await connection.send(openInput());
await waitForEvent(events, (event) => event.type === "session.ready");
await connection.send({
type: "session.prompt",
sessionId: "session-1",
prompt: {
clientMessageId: "first",
delivery: "auto",
input: { type: "message", content: [{ type: "text", text: "first" }] },
},
});
await waitForEvent(
events,
(event) => event.type === "session.turn" && event.state === "canceled",
);
const rows = events.flatMap((event) =>
event.type === "timeline.item" && event.item.id === "tool-1" ? [event.item] : [],
);
expect(rows.at(-1)).toMatchObject({ type: "tool_call", status: "canceled", error: null });
await connection.close();
});
it("serializes configuration mutations", async () => {
let releaseMutation: (() => void) | null = null;
const configOptions = [
+2
View File
@@ -26,6 +26,8 @@ export const PROVIDER_CAPABILITIES = [
export type ProviderCapability = (typeof PROVIDER_CAPABILITIES)[number];
export interface ProviderRegistration {
/** Activate only while agents.providers contains this provider ID, independently of enabled. */
requiresConfiguration?: boolean;
/** Equal keys share discovery within this provider. Include effective configuration and execution environment. */
getCatalogCacheKey?(options: ProviderCatalogOptions): Promise<string | undefined>;
/** Default executable and arguments; the daemon resolves overrides before status/connect. */
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/protocol",
"version": "0.11.0-beta.5",
"version": "0.11.3",
"description": "Paseo shared protocol schemas and wire types",
"files": [
"dist",
@@ -812,3 +812,14 @@ describe("checkout PR schemas", () => {
});
});
});
test.each([undefined, false, true])(
"accepts optional Hoplite capability (%s)",
(hopliteProvider) => {
const features = hopliteProvider === undefined ? {} : { hopliteProvider };
expect(
ServerInfoStatusPayloadSchema.parse({ status: "server_info", serverId: "test", features })
.features,
).toEqual(features);
},
);
@@ -23,6 +23,27 @@ describe("plugin protocol compatibility", () => {
expect(SessionOutboundMessageSchema.parse(message)).toEqual(message);
},
);
it("preserves manifest metadata in plugin list responses", () => {
const message = {
type: "plugin.list.response",
payload: {
requestId: "metadata",
plugins: [
{
id: "review",
name: "Review tools",
icon: "assets/icon.png",
media: ["screenshot.png", "https://example.com/demo.mp4"],
path: "/plugins/review",
enabled: false,
status: "disabled",
},
],
},
};
expect(SessionOutboundMessageSchema.parse(message)).toEqual(message);
});
it("parses plugin timeline append messages and advertises the capability", () => {
expect(
SessionInboundMessageSchema.parse({
+5
View File
@@ -3680,6 +3680,8 @@ export const ServerInfoStatusPayloadSchema = z
commitBaseClassification: z.boolean().optional(),
// COMPAT(providerRemoval): added in v0.1.105, drop the gate when floor >= v0.1.105.
providerRemoval: z.boolean().optional(),
// COMPAT(hopliteProvider): added after v0.11.2, remove after 2027-04-15 once host floor includes the bundled adapter.
hopliteProvider: z.boolean().optional(),
// COMPAT(importSessionWorkspaceTarget): added in v0.1.110, remove gate after 2027-01-16.
importSessionWorkspaceTarget: z.boolean().optional(),
// COMPAT(importSessionSearch): added in v0.8.0, remove gate after 2027-03-02.
@@ -6632,6 +6634,9 @@ export const PluginNpmInstallationSchema = z.object({
export const PluginListItemSchema = z.object({
id: PluginIdSchema,
name: z.string().optional(),
icon: z.string().optional(),
media: z.array(z.string()).optional(),
description: z.string().optional(),
path: z.string(),
enabled: z.boolean(),
@@ -0,0 +1,42 @@
import { describe, expect, it } from "vitest";
import { pluginOverviewUrl } from "./plugin-overview.js";
describe("plugin overview destinations", () => {
it.each([
undefined,
"",
"./docs",
"../docs",
"/login",
"//example.com",
"#section",
"?login",
"http://example.com",
"mailto:hello@example.com",
"ftp://example.com",
"javascript:alert(1)",
"data:image/png;base64,AAAA",
"vbscript:msgbox(1)",
"javascript&#58;alert(1)",
"javascript%3Aalert(1)",
"javascript%253Aalert(1)",
"java\tscript:alert(1)",
" https://example.com",
"https://example.com\n",
"https:example.com",
"https:///example.com",
"https://paseo.sh@evil.example",
"https://example.com\\@evil.example",
"https://",
])("leaves %s as text", (url) => {
expect(pluginOverviewUrl(url)).toBeUndefined();
});
it.each([
"https://example.com/path?q=a&b=c#section",
"HTTPS://example.com/path",
"https://example.com/%3Cscript%3E",
])("allows %s without decoding it again", (url) => {
expect(pluginOverviewUrl(url)).toBe(url);
});
});
+49
View File
@@ -0,0 +1,49 @@
/** Presentation contract for author-controlled registry content, independent of the UI runtime. */
export const pluginOverviewPolicy = {
rawHtml: false,
elements: [
"p",
"br",
"hr",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"blockquote",
"ul",
"ol",
"li",
"strong",
"em",
"del",
"pre",
"code",
"a",
"img",
"table",
"thead",
"tbody",
"tr",
"th",
"td",
],
link: { rel: "noopener noreferrer nofollow", target: "_blank" },
} as const;
/** Validate decoded Markdown destinations; never resolve against the reader's page or repository. */
export function pluginOverviewUrl(value: string | undefined): string | undefined {
// URL parsers discard controls and normalize backslashes; reject those ambiguous spellings.
// eslint-disable-next-line no-control-regex
if (!value || !/^https:\/\/[^/?#]/i.test(value) || /[\u0000-\u0020\u007f\\]/.test(value))
return undefined;
try {
const url = new URL(value);
if (url.protocol !== "https:" || !url.hostname || url.username || url.password)
return undefined;
return value;
} catch {
return undefined;
}
}
@@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import { pluginMediaKind } from "./plugin-registry.js";
describe("plugin media kind", () => {
it.each([
["https://cdn.example.com/a/preview.png", "image"],
["https://cdn.example.com/a/preview.JPG", "image"],
["https://cdn.example.com/a/preview.jpeg", "image"],
["https://cdn.example.com/a/preview.webp", "image"],
["https://cdn.example.com/a/preview.gif", "image"],
["https://cdn.example.com/a/demo.mp4", "video"],
["https://cdn.example.com/a/demo.WebM", "video"],
["https://cdn.example.com/a/demo.mp4?raw=true#t=2", "video"],
["https://cdn.example.com/mp4/preview.png?format=mp4", "image"],
] as const)("reads %s as %s from its path extension", (url, kind) => {
expect(pluginMediaKind(url)).toBe(kind);
});
});
+11 -1
View File
@@ -39,13 +39,21 @@ export const PublishedPluginSchema = z.object({
repository: z.object({ url: z.string().url(), commit: z.string().optional() }),
artifact: PluginRegistryArtifactSchema,
icon: z.string().url().optional(),
screenshots: z.array(z.string().url()),
/** Images and videos in display order; see `pluginMediaKind`. */
media: z.array(z.string().url()),
submittedAt: z.string(),
reviewedAt: z.string(),
updatedAt: z.string(),
publishedAt: z.string(),
installs: z.number().int().nonnegative().optional(),
});
const VIDEO_EXTENSIONS = new Set(["mp4", "webm"]);
/** Whether a registry media entry is a video or an image, from its path extension. */
export function pluginMediaKind(url: string): "image" | "video" {
const extension = new URL(url).pathname.split(".").pop()?.toLowerCase() ?? "";
return VIDEO_EXTENSIONS.has(extension) ? "video" : "image";
}
export const PublishedPluginDetailSchema = PublishedPluginSchema.extend({ readme: z.string() });
export const PluginRegistryIndexSchema = z.object({
schemaVersion: z.literal(1),
@@ -54,6 +62,8 @@ export const PluginRegistryIndexSchema = z.object({
z.object({ slug: z.string(), label: z.string(), description: z.string().optional() }),
),
plugins: z.array(PublishedPluginSchema),
/** Plugin IDs the maintainer features, in display order. An ID may name a plugin that is not listed. */
featured: z.array(z.string()),
generatedAt: z.string(),
});
export type PluginRegistryIdentity = z.infer<typeof PluginRegistryIdentitySchema>;
@@ -3,6 +3,35 @@ import { describe, expect, it } from "vitest";
import { parsePluginSourceReference } from "./plugin-source-reference.js";
describe("plugin source references", () => {
it.each([
".",
"..",
"./owner/slug",
"../owner/slug",
"/owner/slug",
"~/owner/slug",
"~",
"C:\\",
"C:/",
"D:\\plugins\\repository",
"D:/plugins/repository",
"\\\\server\\share",
"\\\\server\\share\\plugins\\review",
".\\plugins\\review",
"..\\plugins\\review",
])("recognizes the explicit directory %s by shape", (source) => {
expect(parsePluginSourceReference(source)).toEqual({
kind: "directory",
source,
pluginPath: undefined,
});
expect(parsePluginSourceReference(`${source}:plugins/review`)).toEqual({
kind: "directory",
source,
pluginPath: "plugins/review",
});
});
it.each([
["npm:review@1.2.0:nested", "npm:review@1.2.0", "nested"],
["npm:review@^1.2.0", "npm:review@^1.2.0", undefined],
@@ -16,6 +45,7 @@ describe("plugin source references", () => {
["git:file:///repo:plugins/main", "git:file:///repo", "plugins/main"],
["plugins.example.com:8443/owner/slug", "plugins.example.com:8443/owner/slug", undefined],
["owner/repository", "owner/repository", undefined],
["plugins/foo", "plugins/foo", undefined],
["owner/repository:plugins/review", "owner/repository", "plugins/review"],
[
"https://example.test:8443/owner/repository.git",
@@ -39,10 +69,8 @@ describe("plugin source references", () => {
"file:///D:/plugins/repository",
"plugins/review",
],
["D:\\plugins\\repository", "D:\\plugins\\repository", undefined],
["D:\\plugins\\repository:plugins/review", "D:\\plugins\\repository", "plugins/review"],
])("parses %s", (reference, source, pluginPath) => {
expect(parsePluginSourceReference(reference)).toEqual({ source, pluginPath });
expect(parsePluginSourceReference(reference)).toEqual({ kind: "managed", source, pluginPath });
});
});
@@ -1,19 +1,27 @@
import { parsePluginRegistryReference } from "./plugin-registry.js";
import type { PluginInstallation } from "./messages.js";
export interface PluginSourceReference {
kind: "directory" | "managed";
source: string;
pluginPath: string | undefined;
}
export function parsePluginSourceReference(reference: string): PluginSourceReference {
if (parsePluginRegistryReference(reference)) return { source: reference, pluginPath: undefined };
const prefix = /^(npm:|github:|git:(?!\/\/))/.exec(reference)?.[0];
const source = prefix ? reference.slice(prefix.length) : reference;
const parsed = splitPluginPath(source, { scp: prefix !== "npm:" });
return { ...parsed, source: `${prefix ?? ""}${parsed.source}` };
if (!prefix && /^(\.\.?(?:[/\\]|$)|\/|~|[A-Za-z]:[/\\]|\\\\)/.test(parsed.source)) {
return { kind: "directory", ...parsed };
}
if (parsePluginRegistryReference(reference))
return { kind: "managed", source: reference, pluginPath: undefined };
return { kind: "managed", ...parsed, source: `${prefix ?? ""}${parsed.source}` };
}
function splitPluginPath(reference: string, options: { scp: boolean }): PluginSourceReference {
function splitPluginPath(
reference: string,
options: { scp: boolean },
): Pick<PluginSourceReference, "source" | "pluginPath"> {
const separator = reference.lastIndexOf(":");
if (separator === -1) return { source: reference, pluginPath: undefined };
@@ -31,6 +31,7 @@ export const ACP_PROVIDER_ICON_NAMES = [
"glm-acp-agent",
"goose",
"grok",
"hoplite",
"junie",
"kilo",
"kimi",
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.11.0-beta.5",
"version": "0.11.3",
"description": "Paseo relay for bridging daemon and client connections",
"files": [
"dist",

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