Desktop logging reaches inherited stdout and stderr before the CLI guards run. A launcher that closes its output reader raised an uncaught EPIPE dialog. Handle closed console pipes at desktop startup while preserving console logging and surfacing other stream errors.
Relative navigation shared the list capped for digit shortcuts, so it wrapped at the ninth workspace. Keep all sidebar targets and cap only numbered badges. Carry forward #4128 with keyboard-routing and browser regressions.
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Raisal P Wardana <IEatCodeDaily@users.noreply.github.com>
* fix(server): start newly configured workspace services without restarting
Extend the workspace port plan when paseo.json declares another service, preserving existing peer ports. Serialize extensions and roll back only their new reservations on failure.
* fix(server): serialize service restarts with port plan extensions
Keep concurrent restart and newly added service allocations in the same workspace update transaction so neither overwrites the other.
* fix(server): keep service port update failures scoped and release cancelled reservations
* test(hub): show creation errors in re-enrollment failures
Assert the error field along with success so failed CI reports include the reason agent creation was rejected.
---------
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
* fix(app): keep long custom models removable
Custom model labels and IDs could consume the row width and push Remove outside the sheet. Allow those text cells to shrink while preserving the button width and short-name alignment.
* test(app): wait for legacy agent creation before checking prompts
Agent-list membership includes initialization. Waiting for finish at that point can return on the initial idle transition before the initial prompt starts. Wait for the matching creation response before checking each completed turn, preserving the existing exact-once assertions.
* test(app): wait for directory search keyboard focus
The search page becomes visible before its deferred focus callback runs. Wait for the input to own keyboard focus so the journey types its query rather than dropping it during navigation.
---------
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
* fix(client): reject a message over the daemon's size limit instead of reconnecting in a loop
The daemon closes the WebSocket with 1009 when a message exceeds its 100 MiB
limit. A workspace or agent creation that loses its connection mid-request
stays pending and, after reconnecting, finds no record of itself on the
daemon (the oversized frame was never read), so it resubmits the same frame
and the connection drops again every couple of seconds until the app reloads.
The client now checks each outbound frame against the same limit, which
protocol exports and the daemon sets explicitly, and fails that request with
a size error while the connection stays up.
Refs #5501
* test(client): cover the UTF-8 byte count at the message size limit
* fix(client): reject oversized relay messages without disconnecting
Check the encrypted wire size synchronously before submission, so a frame below the direct limit cannot close the relay and leave workspace creation resubmitting forever. Keep the relay limit shared with the existing checkout diff budget.
---------
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
* Show Claude option previews in the question card
* Wrap long lines in question option previews
* Check option previews under the option that owns them
* Show the whole option preview on native so every line is reachable
On Android, a slow drag inside a preview capped at 240px did not scroll
it, so lines past the cap could not be read. The preview's nested
scroll sits inside the option's Pressable; the same drag scrolls it
once the preview is outside the Pressable. Native now renders the
preview at full height so the chat scroll reaches every line, and a tap
on it still selects the option. Web keeps the 240px cap.
The mock agent's rollout preview is now taller than the cap so the
Android replay can check the last line.
---------
Co-authored-by: Gabriel Giordano <hello@gabrielgiordano.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
* fix(omp): show manual compaction progress and refresh context usage
OMP answers a manual compact with its RPC response alone, without
compaction_start or compaction_end, so /compact on an OMP agent showed no
row and left the context meter at the pre-compaction value until the next
turn. The compact request now emits the loading and completed rows itself
and publishes the session's context usage once it returns.
The fake OMP runtime no longer emits compaction events for a manual
compact, matching what OMP sends.
Closes#5966
* fix(omp): keep a running turn's usage polling through a manual compact
Refresh the compacted context usage without ending the active turn's
poll, wait for the usage update in the real OMP test, and cover a failed
manual compact.
---------
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
* fix(server): accept voice and model names from self-hosted speech servers
features.voiceMode.tts.voice and TTS_VOICE were validated against the
OpenAI voice catalog, so a server whose voices live under other names
could not be configured at all: config.json failed to parse and an env
var threw inside the speech config resolver, which config.ts calls
without a catch, so the daemon refused to start. TTS_MODEL had the same
problem for model ids the server serves itself. Both are free-form now,
with model ids keeping their case because rosters use values like
kokoro-v1:Q8_0. Regenerate the config JSON schema.
* fix(server): send TTS voice names as written and keep the config schema current
A self-hosted OpenAI-compatible server owns its voice ids and their case, so
lowercasing the configured voice could turn a valid id into one the server
does not know. Keep the published config schema in its existing form and
change only the voice field.
Co-authored-by: khimaros <me@khimaros.com>
* fix(server): drop the TTS baseUrl from the init log
The configured baseUrl can carry a token in its query string, and logging
it is not needed to accept self-hosted voice and model names.
* test(server): await bounded worktree fixture cleanup on Windows
---------
Co-authored-by: khimaros <me@khimaros.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
* fix(cli): list every agent page in agent ls
agent ls made one fetch_agents call and ignored pageInfo.nextCursor, so
it stopped at the daemon's default page of 200 agents without saying
more existed. Follow the cursor until the daemon reports no next page,
as workspace ls and agent delete already do.
Co-authored-by: HTeo <hteo1337@gmail.com>
* fix(cli): page agent ls in creation order
fetch_agents defaults to updated_at order, so an agent that updated
after the first page moved ahead of the cursor and was skipped. Page by
created_at, which never changes; ls sorts the rows itself afterwards.
* test(cli): exercise agent listing with a supplied client
---------
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: HTeo <hteo1337@gmail.com>
* fix(server): keep a symlinked config.json linked when saving
Paseo saved config.json by renaming a temporary file over its path, which
replaced a symlink with a regular file and left the linked file unchanged.
Private file writes now resolve the symlink and replace the file it points at.
Closes#5932
* test(server): check a symlinked config.json stays private
---------
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Adds a regression test for #5928: a child that finishes its initial turn before its turn start returns still sends one finish notification to its parent. The fix itself is on main from 0009a7e.
Co-authored-by: Ahmet İzgi <ahmetizgi@gmail.com>
* fix(app): respect provider label for xhigh thinking option
Only fall back to the localized Extra high string for bare forms.
Custom labels such as XHigh (56 | 8m) now render with their suffix.
* fix(app): show Extra high only for a bare xhigh label
A configured xhigh label with extra detail keeps its text; only a label
that is just xhigh or Extra high uses the localized string.
Co-authored-by: Elior Fureraj <efureraj@gmail.com>
---------
Co-authored-by: Elior Fureraj <efureraj@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
* fix(claude): rewind a session that lives in the provider's CLAUDE_CONFIG_DIR
Rewinding the conversation forks the Claude session through the SDK's
forkSession, which looks for the transcript under the daemon's own
CLAUDE_CONFIG_DIR or ~/.claude. A provider whose env sets CLAUDE_CONFIG_DIR
has its transcripts elsewhere, so every rewind failed with
"Session <id> not found".
Fork through a session store rooted at the folder of the transcript the
agent already resolves with the provider's config dir, so the SDK reads
the session where Claude Code wrote it and writes the fork beside it.
Closes#6321
* fix(claude): pass only transcript entries to the rewind session store
The session store parsed each transcript line and cast it to a session
entry without checking it. Keep only JSON objects with a string type, and
cover transcripts with non-entry lines in the real-SDK rewind test.
---------
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
* feat(app): open files and subagents in a closable sheet on compact
On phones, opening a file, a provider subagent, or a Paseo subagent now shows it
in a bottom sheet over the current tab instead of adding a tab, so the tab strip
stays for terminals and root agents. Paseo subagents get "Open as tab" to move
the same tab (same tabId and state) into a full tab with a composer; agents in a
sheet hide the composer.
The decision lives in the workspace layout store's openTab, driven by a
per-kind `compactSheet` rule in the panel manifest, so every open path (chat
links, Explorer, subagents track, notifications, plugins) follows it without
call-site branching, and a new kind opts in with one manifest entry. The app
shell publishes the layout mode (compact / single-pane / split-panes) once, so
callers stop passing isCompact for placement.
A sheet holding unsaved edits is never implicitly displaced; later opens go to
tabs underneath, and closing it goes through the existing discard guard. The
editor host is position: sticky because CodeMirror's scrollIntoView otherwise
scrolls the bottom sheet's container and drags the sheet to full height.
* test(app): fail the compact sheet snap check when the sheet is oversized
---------
Co-authored-by: paseo-bot[bot] <paseo-bot[bot]@users.noreply.github.com>
* fix(codex): split app-server stdout on newline only, not U+2028/U+2029
node:readline treats U+2028 and U+2029 as line terminators, but the Codex
app-server writes them raw inside JSON strings. Any message containing them
(e.g. tool output or code that mentions JS whitespace) was split into
fragments that failed JSON.parse and were dropped as "non-JSON stdout lines".
Dropped live events could leave an agent stuck in "running", and a
thread/resume response containing them never resolved, leaving the UI on
"Updating messages" indefinitely.
Replace readline with a newline-only line reader (StringDecoder for UTF-8
chunk boundaries) and add a regression test.
* fix(codex): read large app-server stdout lines in linear time
The newline-only stdout reader rescanned the whole accumulated line on
every chunk, so a multi-megabyte thread/resume response took seconds to
frame. Search only the newly decoded text and join the unterminated
parts once the line ends.
Co-authored-by: James Tsang <wtzeng1@gmail.com>
* test(codex): check the large chunked line without a wall-clock limit
Co-authored-by: bebeebebbebebbe <54549544+kotusenka@users.noreply.github.com>
---------
Co-authored-by: Graham <graham@eger.dev>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: James Tsang <wtzeng1@gmail.com>
Co-authored-by: bebeebebbebebbe <54549544+kotusenka@users.noreply.github.com>
The New workspace screen submitted the composer's remembered mode as-is,
so a saved mode for a provider that offers no modes (or no longer offers
that one) failed creation with "Invalid mode '<mode>' for provider
'<provider>'". A new chat tab and the workspace setup dialog already
reconciled the mode against the provider's modes, each with its own copy
of the rule.
The draft composer state now exposes effectiveModeId, the mode the picker
shows, and all three create paths submit it. While the provider is still
loading, the selected mode is kept because its modes aren't known yet.
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Matt Cowger <matt@cowger.us>
* fix(server): allow worktree creation after Git initialization
Fresh project checkout reads could publish Git capability while worktree creation retained a cached non-Git snapshot. Refresh an existing snapshot when the fresh read observes a classification change, before returning the new capability.
* test(server): cover Git initialization in the standard integration suite
* test(server): separate stress-test writes from deletion delivery
Wait for active classifications and queued deliveries between the distinct write and deletion phases. Otherwise a late create and delete coalesce as update, leaving the strict deletion assertion waiting indefinitely. Preserve every event assertion without waiting for scheduled native safety audits.
* test(server): compare canonical repository paths on Windows
---------
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Seed active usage from the explicit 1M modifier and the model resolved by Claude Code initialization. Preserve SDK-reported limits on repeated initialization and reset them when the selected model changes.
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
* fix(server): release ACP provider process after history resume
Resuming a persisted agent with purpose "history" only replays the
session into the canonical timeline, but ACP sessions kept the spawned
CLI process resident afterwards. Every client that fetches an archived
agent's timeline (the app reconnects fetching every visible agent)
therefore re-materialized a provider process per archived agent and
nothing ever released it — measured ~280 MB resident per dsh agent.
After the replay and configured overrides finish, a history-purpose
resume now settles the commands gate, drops the connection, and
terminates the child. The session object stays registered with the
replayed history so read paths keep working; the existing
`this.child !== child` exit guard already ignores the released
process, and interactive use goes through reloadAgentSession which
spawns a fresh process. This matches the runtime-only contract the
Codex provider already implements.
* fix(server): retain child refs when history-runtime release times out
Addresses Greptile review: if terminateProcess returns kill-timeout the
child stays registered so close() can retry instead of leaking an
untracked process. The test now asserts the process is already dead when
resumeSession resolves, which pins the documented release-before-return
contract instead of a post-hoc poll.
Greptile iteration 1.
* fix(highlight): render Astro diffs containing empty expressions
Empty Astro expressions produced zero-length nested JavaScript ranges, which rejected the whole diff. Leave those expressions in the markup tree and preserve highlighting around them.
* test(highlight): make empty Astro expression assertions explicit
---------
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
* fix(client): restore provider readiness on mobile
React Native uses an AbortSignal without throwIfAborted. Check aborted with the existing API disposal error so readiness works while preserving cleanup and cancellation.
Co-authored-by: GeorgeR <897457+GeorgeR@users.noreply.github.com>
* test(plugins): run mobile readiness regression in CI
---------
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: GeorgeR <897457+GeorgeR@users.noreply.github.com>
* Keep pairing QR code within compact tile
* Move pairing screenshot after viewport assertion
* test(pairing): verify the complete QR code on Android
The native regression failed with the fixed-size SVG because the code was clipped at both screen edges. Verify the complete square and visible margins through the Android app, and keep browser geometry assertions in the pairing helper.
Co-authored-by: fdkevin <25277753+fdkevin0@users.noreply.github.com>
---------
Co-authored-by: fdkevin <25277753+fdkevin0@users.noreply.github.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
* fix(app): run recorded letter shortcuts with non-Latin layouts
Recording unsupported letters saves the physical A-Z key, but dispatch rejected that same event. Accept its letter code while keeping ASCII character matching for layouts such as Dvorak.
* fix(app): run recorded dotted-letter shortcuts
Check the original single-letter key before case folding expands uppercase dotted I into two code points.
---------
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
* fix(server): reach IPv6-only workspace services
Workspace service forwarding and health probes forced IPv4 loopback,
returning 502 and unhealthy for a reachable IPv6-only service. Select
between both literal loopback addresses for HTTP, upgrades, and probes,
without relying on the system's localhost DNS entries.
Co-authored-by: Marcelo Barbosa <celocb@gmail.com>
* test(server): keep proxy POST mechanics in a helper
---------
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Marcelo Barbosa <celocb@gmail.com>
* 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.
* fix(app): render local relative images in markdown file preview
File preview dropped relative images because it only accepted http/data URIs and never resolved paths against the opened markdown file.
* test(app): cover local relative images in markdown preview e2e
* fix(app): show local images in Markdown file previews
Carry forward document-relative image loading and preserve the host home
directory when resolving images beside home-relative Markdown files.
Decode file URIs once, and require decoded images in the file-pane regression.
Co-authored-by: Ouyang Xingyuan <galaxyroaming@qq.com>
* fix(app): resolve local Markdown image URL suffixes
---------
Co-authored-by: Ouyang Xingyuan <galaxyroaming@qq.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Publish the existing thinking-option event as soon as Pi changes its effective level, keeping subscribers and persistence in sync before the next state refresh.
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
* fix(app): install navigator polyfill before any app module loads
@xterm/headless reads navigator.userAgent at module evaluation. Since
#6455 the root layout transitively imports it, so the layout's
module-level polyfillNavigator() ran too late and native builds crashed
at launch with "Cannot read property 'includes' of undefined".
Install the crypto, navigator and screen.orientation polyfills from a
side-effect module imported first by index.ts. Calls written in index.ts
itself ran after all hoisted imports had evaluated.
Fixes#6504
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(app): isolate native startup regression from shared globals
Exercise the real navigator polyfill and terminal dependency in fresh VM contexts, including the pre-initialization failure. Preserve a check that entry imports install navigator before the app loads.
Co-authored-by: panrafal <rl@stamina.pl>
* test(app): follow startup import lint rule
---------
Co-authored-by: panrafal <rl@stamina.pl>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
* fix(desktop): show the window when ready-to-show never fires on Linux
Reveal hidden Linux windows that do not receive an initial compositor frame.
* fix(desktop): open loaded Linux windows without waiting for a frame
A hidden Linux window can finish loading without painting, so waiting only for ready-to-show leaves the application invisible. Reveal the loaded document and retain the frame-ready path on other platforms.
Co-authored-by: mistop <jhlim@mistop.org>
---------
Co-authored-by: mistop <jhlim@mistop.org>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Links to a new issue on the plugin's GitHub repository, from the registry's repository URL. Monorepo folder URLs resolve to the repository; non-GitHub sources show no button.
Co-authored-by: paseo-bot[bot] <paseo-bot[bot]@users.noreply.github.com>
A card whose description has an unbreakable word, such as xpufx/slash's raw
README HTML, sized its grid column to that word and widened browse pages to
564px at a 390px viewport. The card now has min-width 0 and its description
wraps long words.
Co-authored-by: paseo-bot[bot] <paseo-bot[bot]@users.noreply.github.com>
* List new plugins by first listing, add Recently updated, and show authors on grid cards
What's new and the Newest tab sort by publishedAt, which the registry now keeps
at the first listing. Recently updated lists plugins whose current version was
approved after their first listing, on the directory and as a browse tab. Browse
and author grid cards show the author's avatar and name like the directory cards.
* Rename the card image helper so it does not shadow a test parameter
---------
Co-authored-by: paseo-bot[bot] <paseo-bot[bot]@users.noreply.github.com>
* fix(app): sync Android system bar icon style with app theme
With edge-to-edge enabled the app draws under the Android system bars,
but the bars keep their default light icon style regardless of theme,
so the status bar clock/battery/icons are invisible on light themes
(white on white). Call SystemBars.setStyle from the appearance provider
whenever the theme is applied: dark icons on light themes, light icons
on dark themes, auto following the system for adaptive themes and
plugin-contributed themes.
Refs #1272 (runtime only; the splash screen phase is not covered).
* fix(app): keep Android status icons readable as themes change
Keep system-bar contrast subscribed to the resolved app theme so automatic appearance changes update the icons as well as the app surface. Add a native regression covering explicit and automatic themes.
Co-authored-by: TeraYang <4110825+terayang@users.noreply.github.com>
* fix(app): map system-bar appearance through color scheme
Unistyles reserves style mappings for React Native style objects. Keep SystemBars style selection inside its appearance adapter and map the colorScheme prop instead.
---------
Co-authored-by: TeraYang <4110825+terayang@users.noreply.github.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
* fix(claude): read cumulative input usage from message_delta
The context meter took input_tokens only from message_start. Backends behind a
translating gateway report 0 there and the real cumulative counts on
message_delta, so contextWindowUsedTokens collapsed to output_tokens.
Keep the message_start counters per field and let a counter present on
message_delta replace it, the same rule the Anthropic SDK MessageStream uses.
A missing counter keeps the message_start value, so a delta without cache
fields does not drop cache_read_input_tokens.
Closes#4294
* test(claude): ignore a message_delta that precedes its message_start
A canceled request can leave its terminal message_delta queued ahead of the
next turn. Pin that such a straggler neither emits usage_updated nor leaks
into the next request's counters.
* test(claude): take the message_delta usage object as a single parameter
Keep createMessageDeltaEvent(outputTokens) for existing call sites and add
createMessageDeltaUsageEvent(usage) for the tests that set input counters on
the delta, instead of a second positional parameter.
* test(claude): verify late input usage through the real provider process
Compare start-side and delta-side counters through two real provider turns against a local Messages API fixture, including omitted cache counters. The delta cases fail on the baseline with output-only context totals.
Co-authored-by: kywoo26 <kywoo26@naver.com>
* test(claude): always close the context usage fixture
---------
Co-authored-by: kywoo26 <kywoo26@naver.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
* fix(app): jump the chat outline to prompts a timeline plugin draws
A timeline transformer replaces a stream item with plugin rows whose ids
are "<pluginId>/<itemId>". The chat outline, chat find and history reveal
look a message up by the source item's id, so once a plugin drew the
prompts the outline found no row: the rail showed the prompts and
clicking one did nothing, for mounted, virtualized and unloaded prompts
alike.
Projected plugin rows now carry the id of the item they replaced, and
getStreamItemMessageId answers with it. Row wrappers, the virtualized
index lookup, the visible-message set and history reveal all read that
function, so they now find the plugin row.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(app): verify outline jumps to virtualized plugin prompts
Replacing every prompt keeps all loaded rows mounted, so the original plugin regression never exercised a virtualized target. Retain newer native prompts and verify that a loaded unmounted plugin prompt lands without fetching.
Co-authored-by: luopeihuan <luopeihuan@roundreddot.com>
* test(app): retain outline navigation screenshots
---------
Co-authored-by: luopeihuan <luopeihuan@roundreddot.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>
* feat(app): add fullscreen editing to the compact composer
* fix(app): preserve read-only terminal command previews
Static command previews must render their published source; the live editor buffer is updated after render and has no mounted input to repaint read-only content.
* fix(app): import React for composer editing scope rendering
Workspace pane unit tests use the classic JSX transform, so the editing-scope provider needs React in scope when mounted.
* fix(app): preserve fullscreen composer controls and editing
Keep the fullscreen frame inside the visual viewport, gate hidden autocomplete keys, apply replacement commands to local line measurement, and return to inline before starting voice so its controls stay accessible.
* test(app): cover draft replacement through the production composer
Replace the measurement-hook fixture with profile switching and typing through the real composer. Keep measurement state inside its production surface; Android manual red/green evidence covers the native replacement flow.
* fix(git): preserve the base worktree path when merging
* test(git): cover merging into whitespace worktree paths
* test(git): follow the server object parameter convention
* test(git): keep merge fixture line endings deterministic
Disable automatic CRLF checkout in the fixture repository so the exact-content assertion works with Windows Git defaults.
Co-authored-by: 见安 <73627805+Mar-garet@users.noreply.github.com>
---------
Co-authored-by: 见安 <73627805+Mar-garet@users.noreply.github.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
* fix(server): report canceled child turns to their callers
* fix(server): wait for replacement child tasks before notifying callers
A displaced turn emits cancellation while its replacement is still running. Notify on terminal events only once the child becomes idle, so Stop and Reload report cancellation while replacement prompts produce one eventual outcome.
Co-authored-by: 见安 <73627805+Mar-garet@users.noreply.github.com>
* test(server): remove debug output from cancellation regressions
---------
Co-authored-by: 见安 <73627805+Mar-garet@users.noreply.github.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Expo declares NODE_ENV readonly after web startup. Preserve the early test environment setup with Object.assign and exercise the real configuration with Expo types in a failing-first compiler regression.
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
* fix(app): record shortcuts using the keyboard layout character
Shortcut capture stored the QWERTY position while dispatch matched the logical character, so a Dvorak B became Cmd+N. Use the existing logical key mappings for capture and preserve code fallback for named and rewritten Option keys.
* fix(app): keep named shortcut keys on their supported codes
Character capture must not advertise unsupported named keys. Restrict the logical lookup to printable mappings so keypad Enter keeps its existing unsupported behavior.
---------
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
* test(server): isolate subprocess regressions from terminal colors
Inherited NO_COLOR and FORCE_COLOR make Node emit an unrelated warning. It contaminates argv-fixture stderr and fails the closed-output supervisor fixture before its pipe handlers are installed. Clear both settings only in these fixture subprocesses, preserving their output and lifecycle assertions.
* test(server): exercise conflicting colors regardless of parent environment
Run the closed-output and spaced-executable regressions with explicit conflicting color inputs as well as the inherited environment, so removing fixture isolation fails on ordinary CI environments too.
---------
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Packaged windows read resources/icon.png, but Linux packages only
installed desktop-entry icons. Include the runtime PNG and verify its
bytes in the existing packaged artifact smoke test.
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Ivan Pantic <panta@talentkit.io>
Co-authored-by: Max Höhl <43786929+whme@users.noreply.github.com>
* fix(server): find hidden agent plan files in workspace suggestions
Workspace autocomplete skipped the editor's plan directory while traversing
other known agent directories. Include it in the existing discovery policy
and cover basename and partial-path queries through the daemon client.
Co-authored-by: X <X@16x.org>
* test(server): give plan suggestion queries explicit expectations
---------
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: X <X@16x.org>
* fix(server): give the web UI connection hint an explicit port (#1)
injectConnectionHint built `listen` straight from the Host header, which
carries no port when the client used the scheme's default — exactly what a
browser sends to a proxy terminating TLS on 443. The app parses `listen` with
parseHostPort, which requires an explicit port, so connectionFromListen
returned null and bootstrapInitialConnectionHint bailed before probing,
silently. Boot then fell through to the localhost:6767 fallback, which is the
viewer's own machine rather than the daemon, and the app landed on the welcome
screen with no host at all.
Reaching the daemon directly never hit this: http://host:6767 puts the port in
the Host header. It only appears behind a reverse proxy on 443 or 80, such as a
Tailscale Ingress, and only in a browser profile with no host already stored.
Spell the scheme default out when the header omits it, leaving the IPv6 bracket
form intact.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* fix(server): compute the web bootstrap port before its connection hint
Keep the default-port fix for reverse proxy bootstrap within the repository branching style.
Co-authored-by: Sian Lerk Lau <kiawin@gmail.com>
---------
Co-authored-by: Sian Lerk Lau <kiawin@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
The provider regression test rejected valid GPT catalogs solely because no ID contained codex. Keep the structural output checks without requiring that upstream branding substring.
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
* fix(config): accept daemon settings in the published editor schema
The website schema omitted accepted configuration fields, so editors
rejected valid browser, profile, plugin and metadata settings. Regenerate
from the daemon schema and guard the published artifact against drift.
Co-authored-by: Okan YILDIRIM <63511519+ragokan@users.noreply.github.com>
* fix(config): preserve proxy help when regenerating the schema
Keep the existing editor descriptions in the source schema so regeneration retains them. The new regression fails when the proxy descriptions disappear.
* test(usage): create SQLite store fixtures in one transaction
The Windows plugin suite exceeded its five-second test deadline while creating and exercising the first OMP store fixture. Commit the fixture as a unit rather than flushing each inserted row separately; preserve the read-only discovery assertions.
* test(config): show structured schema validation errors
---------
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Okan YILDIRIM <63511519+ragokan@users.noreply.github.com>
* fix(cli): await permission resolution before closing transport
* test(cli): verify permission confirmation over real connections
Exercise permission commands through a real WebSocket protocol peer, keeping responses pending until explicit resolution. This catches success before acknowledgement without replacing the client connector or process output. Retain the contributor permission fix and verify both commands, batches, and unconfirmed responses.
Co-authored-by: Budi <11062155+budivoogt@users.noreply.github.com>
* test(cli): catch permission batches completing after one reply
Hold the second acknowledgement until a WebSocket round trip proves the first reply reached the client. A first-reply-only mutation must fail both batch tests.
* test(cli): inject permission clients into command regression tests
* test(cli): use timer helper for permission continuations
* style(cli): format permission regression tests with oxfmt
---------
Co-authored-by: Budi <11062155+budivoogt@users.noreply.github.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
* fix(file-explorer): distinguish parent traversal from dot-prefixed names
* test(file-explorer): cover dot-prefixed paths through daemon RPC
* test(plugins): allow slower session readiness in provider fixtures
The event helper exhausted 200 iterations before a valid delayed session
became ready. Use an elapsed-time deadline while preserving real timers
for watchdog tests, and retain a delayed-start regression with launch
flag assertions.
Co-authored-by: 见安 <73627805+Mar-garet@users.noreply.github.com>
---------
Co-authored-by: 见安 <73627805+Mar-garet@users.noreply.github.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
The html-ish pass feeds markdown to htmlparser2 so it can render
<details>, <img> and friends. htmlparser2 reads any `<` followed by a
letter as a tag and collects attributes up to the next `>`. A cell such
as `1<ratio<2` in a table therefore swallowed the rows after it, and the
re-serialized "tag" dropped the newlines and pipes that made them rows.
The same path broke autolinks (`<https://x/y>` became `<https: x y>`)
and cut text after a trailing `a<b`.
Only hand htmlparser2 what CommonMark reads as raw HTML: open tags,
closing tags and comments. Every other `<` stays text.
Unknown tags are rendered back as text, but they were rebuilt from the
parser's lowercased name and attributes, so `As<Integer>()` showed as
`As<integer>()`. Echo the source slice instead.
* fix(tests): run React tests from production shells
Vitest preserves an inherited NODE_ENV, selecting React without its test APIs. Pin the environment before dependency resolution and worker startup in both test entry points.
Co-authored-by: Ivan Malison <IvanMalison@gmail.com>
* test(app): expect zero edits for a pure staged rename
The rename patch correction on main preserves unchanged content. Keep the discard journey intact and update its obsolete added-line assertion.
---------
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Ivan Malison <IvanMalison@gmail.com>
Hunk content resembling patch headers was discarded before line parsing. Metadata is already ignored before the first hunk, so preserve every added and removed line once a hunk starts.
Closes#6564
Co-authored-by: Yu Han <73627805+Mar-garet@users.noreply.github.com>
* fix(server): close canceled HTTP download streams
* test(server): verify canceled downloads release real file descriptors
Exercise the reported HTTP cancellation path without replacing shared Node methods, and include download regressions in the standard integration suite.
Co-authored-by: 见安 <73627805+Mar-garet@users.noreply.github.com>
* test(server): detect canceled download leaks through symlinked paths
Compare descriptor targets with the resolved file path and exercise a symlinked download directory so the regression cannot pass by missing the open file.
---------
Co-authored-by: 见安 <73627805+Mar-garet@users.noreply.github.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
* fix(server): show actual edits when a file is renamed
Path-limited patch requests discarded the detected rename source, so Git reported the destination as a new file. Include both literal paths and select the destination patch to preserve the rename and avoid duplicating changed copy sources.
* fix(server): preserve small copy patches beside oversized sources
Filter rename and copy output before enforcing the bounded request budget, so a modified source cannot hide its small copy patch.
---------
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Preserve returned change request bodies as search-only option text so client filtering keeps description matches. Keep title and ID matches ranked ahead of secondary text.
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Command-scoped user.name overrides the fixture repository configuration and breaks its author assertion. Supply the fixture author when creating commits and exercise the override with real Git.
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Format projected text entries in sequence instead of grouping replies ahead of their preceding reasoning. Timeline projection already owns fragment merging.
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
The failing signer uses gpg.program, which Git bypasses when a global SSH signing format is inherited. Select OpenPGP in the commit and squash-merge test repositories so both checks exercise their configured failing signer.
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Request uncolored patches with explicit a/ and b/ prefixes before parsing. Mnemonic paths do not match checkout entries, and forced ANSI colors hide patch headers and hunks.
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
* fix(server): claim scheduled slots atomically
* fix(server): skip elapsed schedule slots
* test(server): isolate overlapping tick runner
* test(schedule): reproduce duplicate cron dispatch across overlapping ticks
* fix(schedule): preserve final slots and recovery after failed outcome writes
Historical running records are not proof of a live execution after an outcome write fails. Keep live-run exclusion in the existing in-memory guard while the persisted cursor prevents duplicate slot claims. Final allowed runs do not need a future cron occurrence; recover interrupted final runs and preserve cadence if their limit changes during execution.
---------
Co-authored-by: Keith Kowal (paseo-dev) <keithpk+paseo-dev@kowal.me>
Co-authored-by: keithpk <keith@kowal.me>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
* fix(cli): keep streamed message fragments together in logs --follow
`paseo logs` without `-f` fetches the timeline projected, so
`projectTimelineRows` merges consecutive assistant and reasoning fragments by
exact concatenation before the transcript is rendered. Follow mode never got
there: it called `formatAgentActivityTranscript([item])` per arriving event, so
every fragment was projected alone and printed on its own line. Words broke at
fragment boundaries, a reader could not tell an author newline from a transport
one, and `[Thought]` was reprinted for each reasoning chunk.
Follow mode now keeps a fragment chain open using the same merge rule the
projection uses (same kind, same `turnId`, matching `messageId` when the event
carries one) and writes the text as it arrives instead of one line per event.
Only trailing whitespace is held back, so a message still ends trimmed the way
the one-shot transcript ends it, and the chain closes when another kind of item
arrives or the follow stops.
* test(cli): place daemon follow regression in its owning suite
* fix(cli): preserve filtered log boundaries and run the follow regression in CI
* test(cli): wait for a completed log line before terminating follow
---------
Co-authored-by: Lorenzo Fiore <fiorelorenzo.fl@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
* fix(gitlab): find merge requests and issues by number
GitLab text search matches titles and descriptions but not item numbers. Use repository-scoped IID filtering for numeric queries, keeping open-only results and the existing text search behavior.
* fix(gitlab): retain text matches in number searches
Supplement title and description search with the open-item number lookup, rather than replacing it. Prioritize exact numbers, deduplicate results, and preserve each list limit.
* refactor(gitlab): name list helper arguments
---------
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
* fix(app): decode percent-encoded assistant file links
markdown-it percent-encodes non-ASCII and space characters in link
hrefs, so a link like [x](docs/reports/报告.md) arrives as
docs/reports/%E6%8A%A5%E5%91%8A.md. The file:// and bare absolute-path
branches already run safeDecodeURIComponent, but the workspace-relative,
path:line, Windows, and home-relative branches did not, so clicking the
link tried to open the still-encoded name and failed with ENOENT.
Decode the path portion before the relative-path heuristics and in the
inline absolute/Windows branches so all href forms behave the same.
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* fix(app): decode hrefs only, never raw inline-code paths
Raw inline-code and linkified tokens reach the same parser as markdown
hrefs. Decoding unconditionally would send a real file named
my%20file.md to my file.md. Gate every decode on AssistantHrefParseOptions.decodeHref — set by the resolver only when the token
came from source.href — and extract parseWindowsFileLink to keep the
entry point under the complexity limit. Adds parse-level and
resolver-level regression tests for literal %20 filenames.
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* fix(app): keep raw absolute paths out of URL parsing
new URL() percent-encodes non-ASCII, so a raw inline-code path like
/tmp/开户.md became /tmp/%E5%BC%80%E6%88%B7.md when decodeHref was
false. Hrefs still go through URL parsing (normalizes dot segments
before decode); raw paths split the # fragment manually instead.
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* fix(app): keep encoded and literal file links distinct
Preserve inline-code provenance for Windows paths and key cached file lookups by their resolved query, path, and line range. Reproduce the reported non-ASCII Markdown link through the renderer and filesystem, with browser coverage for clicking it.
Co-authored-by: Ouyang Xingyuan <galaxyroaming@qq.com>
Co-authored-by: dengjiawen <dengjiawen@hortorgames.com>
* test(app): assert file content after opening the markdown link
Co-authored-by: sunshinehomeyu-blip <267551119+sunshinehomeyu-blip@users.noreply.github.com>
* fix(app): preserve encoded line markers in filenames
---------
Co-authored-by: Ouyang Xingyuan <galaxyroaming@qq.com>
Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: dengjiawen <dengjiawen@hortorgames.com>
Co-authored-by: sunshinehomeyu-blip <267551119+sunshinehomeyu-blip@users.noreply.github.com>
* feat(app): modernize compact headers and sidebar affordances
Use a shared island header with quick tab creation and keep sheet footers inside their visible layout. Opt sidebar rows into touch sizing without changing global appearance settings.
* test(app): match darker light-theme workspace selection
* test(app): align plugin journeys with compact header
Failed bridge requests discarded the daemon error body before OpenCode could show the validation explanation to the agent. Preserve that message and status while retaining the HTTP fallback.
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
* fix(cli): honor --since in agent logs
* test(cli): verify logs cutoff in the daemon-owned suite
Keep the real CLI and WebSocket regression while placing daemon fixtures
in the server suite, as the CI ownership contract requires. Invoke the
source CLI so the test runs with the server suite's dependency build.
Co-authored-by: 见安 <73627805+Mar-garet@users.noreply.github.com>
* test(server): use a portable loader URL for the logs regression
---------
Co-authored-by: 见安 <73627805+Mar-garet@users.noreply.github.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
* fix: prevent file mention autocomplete from triggering on emails
Skip @ when preceded by a word character or +, and reject angle
brackets in mention queries so email addresses no longer open the
file autocomplete or steal Enter.
Closes#1364
* test(app): verify emails send intact without file completion
The word-character and plus guard tests previously passed because their queries included trailing spaces. Exercise each guard with the cursor at the end of the email, and cover Enter in the browser alongside normal file completion.
Co-authored-by: Jason <mildreins@gmail.com>
---------
Co-authored-by: Jason <mildreins@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
* fix(cli): pass cwd to worktree ls and archive
worktree ls/archive called getPaseoWorktreeList({}) which fails because
the daemon requires cwd or repoRoot. Default to process.cwd(), accept
optional --cwd, and cover both with unit and CLI help tests.
Closes#2010
* test(cli): reproduce worktree listing without repository scope
* fix(cli): resolve local worktree paths from the calling directory
Local instance paths belong to the CLI working directory. Explicit endpoint paths remain daemon-relative, matching the project command convention. Cover both through the real daemon.
* refactor(cli): make repository directory selection explicit
---------
Co-authored-by: Jason <mildreins@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
* fix: resolve real base branch for worktrees checked out from an existing branch
When a worktree is created by checking out an already-existing branch,
the stored baseRefName was set to the branch's own name instead of the
repository's default branch. The diff panel then diffs the branch
against itself, always showing no changes.
Fixes#1873
* fix: fall back to the current branch when no default branch is found
Addresses Greptile review feedback on #1874: resolveRepositoryDefaultBranch
returns null for local-only repos with no origin/HEAD and no local
main/master (e.g. a repo whose trunk is "develop"), which previously
fell back to storing the checked-out branch as its own base — the
exact self-diff bug this PR fixes. Fall back to whichever branch is
currently checked out in the main repo instead.
* fix(server): preserve existing-branch diffs through workspace restore
Carry the resolved comparison base from the worktree creator into the workspace record. Resolve it for occupied branches too, so a fresh suffixed checkout still displays the feature's committed changes.
Co-authored-by: 2takumax-chura <takuma.okayama@churadata.okinawa>
* fix(server): skip deleted default refs when creating worktrees
---------
Co-authored-by: 2takumax-chura <takuma.okayama@churadata.okinawa>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
* fix(app): restore terminal surface after tab hide so CJK cells redraw
Retained panels use display:none. Coming back remasured without treating
that as a surface restore, so Hangul kept 2-cell slots while glyphs painted
at ~1 cell (extra gap). Reload WebGL if the context was lost and force a
refresh when the host grows from zero.
Closes#3434
* fix(server): keep wide-char continuation cells out of terminal snapshots
Wide chars (CJK) occupy two xterm cells, and the second cell reports no
chars. The snapshot capture collapsed that empty continuation to " ", so
every restored Hangul syllable took three columns instead of two. The
injected space was real: selecting and copying the restored row carried
it into the clipboard.
Capture the continuation as "" and have renderTerminalSnapshotToAnsi
skip it. No schema change; snapshots from older daemons still replay
verbatim.
* fix(app): restore retained terminals on presentation
Co-authored-by: seob717 <dev.seob717@gmail.com>
* fix(terminal): limit restoration changes to confirmed CJK corruption
Keep the independently reproduced snapshot fix. Surface recovery behavior remains unverified and belongs with the broader viewport report. Dispose the replay terminal after the regression test.
---------
Co-authored-by: Jason <mildreins@gmail.com>
Co-authored-by: seob717 <dev.seob717@gmail.com>
Co-authored-by: liujin0506 <liujin0506@qq.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
* fix(android): declare the camera optional so camera-less devices can install
Requesting CAMERA makes Android treat android.hardware.camera as a required
feature, so Google Play marks devices without a camera as incompatible.
Pairing works without QR scanning, so declare the camera features optional.
Fixes#6303
* test(app): resolve the manifest with the Expo CLI's prebuild packages
Load @expo/prebuild-config and @expo/config-plugins from @expo/cli instead
of the older hoisted copies, and set the F-Droid flag explicitly for each
build profile case.
---------
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
* feat(review): edit compact diff comments in a sheet
* feat(review): send diff feedback to workspace agents
Send saved reviews from the diff while retaining file navigation and unrelated composer drafts. Clear acknowledged versions only so comments edited or added during submission remain available.
* fix(review): preserve drafts and coordinate feedback across surfaces
Share pending submission ownership by draft while keeping recipient menus local. Retain editable text across presentation changes and acknowledgement, and reserve safe-area clearance for floating actions.
* fix(review): center a small primary feedback button on desktop
* fix(review): restore focus to feedback button after closing menu
* feat(claude): add Mythos 5.1 with its model capabilities
* feat(claude): gate Mythos on advertised model identity
* fix(claude): retain capabilities for explicitly configured Mythos
* test(usage): load SQLite before timed store tests
The first Windows OMP store case exceeded its five-second budget while subsequent equivalent cases completed under a second. Load the builtin during test collection so its cold initialization does not consume the first test deadline.
* test(workspaces): publish complete service environment fixtures
Waiting for file existence races with writeFileSync creating the file before writing its contents. Rename completed JSON snapshots into place so the existing readiness check cannot expose an empty file.
* feat(server): let clients create internal agents
The daemon's own branch-name and commit-message generators run as
internal agents: never persisted, listed, flagged, announced, or seen by
plugin lifecycle hooks. Nothing outside the daemon could ask for one,
because the wire config schema strips `internal` and the agent.create
transform re-pins it. A plugin that runs a one-turn helper per event
therefore left an archived session in History every time.
Add an optional `internal` flag to `create_agent_request` (inherited by
`agent.create.request`), copy it onto the config in the session's create
path, and skip provider session persistence for it like the generators
do. The client SDK forwards the flag on both creation paths and gates it
once on `server_info.features.internalAgents`.
Two daemon paths assumed every agent is public and had to follow:
`resolveAgentIdentifier` now accepts a live agent by exact id, since the
id set it matched against came from listings that exclude internal
agents and left `wait_for_finish` answering "Agent not found"; and
archiving an internal agent now closes it without writing the record
that `registry.applySnapshot` used to leave behind, so `autoArchive`
does not accumulate hidden files on disk.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(server): keep internal agents whole through failure, archive, and wait
Review follow-ups for internal agents:
- A create that fails after the runtime is registered now closes the
internal agent before rethrowing, so a hidden runtime cannot outlive
the failed request. Public agents keep today's behaviour.
- Archiving an internal agent cascades to attached children like any
parent. cascadeArchiveChildren takes the parent record instead of
reading it back from storage, where an internal agent never is.
- The archived snapshot and last message of an internal agent stay
readable by exact id for ten minutes, so create-then-waitForFinish
returns the finished turn whether the wait lands before or after an
auto-archive. Storage plays that role for public agents.
- The internal-agent e2e scenarios live in the existing
create_agent_request lifecycle suite rather than a new file.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(server): cascade to live internal children and keep a failed internal create's worktree until it closes
Internal children never reach storage, so the parent archive cascade scans
live agents for them as well. A failed internal create now clears the
created id only after the runtime is archived, so worktree cleanup cannot
delete the directory under a runtime that would not close. Stored agent
payloads carry lastError, so a retained failed helper reports its error
through wait_for_finish instead of the generic message.
* feat(cli,mcp): expose internal agents and document --no-wait
paseo run gains --internal and create_agent gains internal, both forwarded
to the create request. --no-wait becomes the documented spelling for
returning immediately, matching paseo send; --background and --detach stay
as hidden aliases under COMPAT tags.
* test: follow the --no-wait rename and stub getRetiredInternalAgent
* feat: list internal agents on request and add internal workspaces
Internal agents stay hidden by default, but a caller can now ask for
them: `fetch_agents_request` takes `filter.includeInternal`, snapshots
carry `internal: true`, and `paseo ls --internal` shows them in an
INTERNAL column. The app never passes the flag.
Workspaces get the same flag. `workspace.create.request` with
`internal: true` persists a hidden workspace: listings, update
subscriptions, sync reads, empty-project counting, open-by-path, MCP
`list_workspaces`, and plugin workspace hooks all skip it unless the
caller opts in with `filter.includeInternal`. Every agent created inside
an internal workspace is internal and unpersisted; AgentManager applies
that from the workspace id before the plugin hook so creators cannot
opt out. Archive and shutdown enumerate internal agents so nothing
inside an internal workspace outlives it.
Surfaces: `paseo workspace create --internal`, `paseo workspace ls
--internal`, `paseo run --internal --new-workspace`, MCP
`create_workspace({ internal })`, SDK `workspaces.create({ internal })`
and `list({ filter: { includeInternal } })`. Clients gate on
`features.internalWorkspaces`.
Co-Authored-By: Claude <noreply@anthropic.com>
* feat(workspaces): add background visibility with inherited creation defaults
* fix(cli): resolve workspace callers on the selected daemon
* test(workspaces): keep caller integration coverage in server suite
* test(cli): exercise workspace callers through CLI integration harness
* test(mcp): remove obsolete execution flags from browser fixtures
---------
Co-authored-by: marvin-ambrozio <marvin@gustavo.eng.br>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
* fix(agents): preserve creation when the first prompt fails
A rejected initial prompt must leave a created agent with a turn error instead of returning the user to a draft whose creation key is already committed. Preserve receipt replay without repeating the prompt, and bound fork history to 100,000 characters before it becomes an attachment.
* test(creation): isolate user agent counters from background naming
Workspace naming uses internal provider sessions. Counting and rejecting those sessions made creation assertions depend on installed provider discovery timing. Keep discovery within the test adapters and scope observations to user-created agents.
* test(creation): retry temporary directory cleanup on Windows
Windows CI completed the rejection assertions but failed teardown with EBUSY. Match the daemon test harness bounded removal retries so transient directory locks do not fail creation tests.
* fix(agents): record rejected prompts before their errors
Startup rejection recorded only the system error, leaving the client to append its local prompt after it. Record the rejected submission at the terminal failure boundary so stream and fetched history agree on prompt-before-error ordering.
* fix(agents): require acceptance for delivery notifications
Retain rejected human submissions before their errors without projecting rejected inter-agent prompts as completed deliveries. Reproduces and fixes the notification regression exposed by merge CI.
* test(agents): retain rejected submissions across retries
The existing browser retry test assumed terminally rejected prompts disappeared. Assert the intended prompt-before-error history and a separate accepted retry while preserving draft, attachment, and ambiguous-steering checks.
* test(creation): allow integration latency in raw RPC responses
The peer inherited a one-second polling timeout despite exercising Git and provider startup. Reproduce the timeout with delayed startup and wait within an explicit integration budget while retaining all response and identity assertions.
* feat(agents): show agent messages and completion notifications with sender identity
* fix(agents): preserve human envelope examples and attachment context
* fix(agents): separate message provenance from timeline presentation
Discovery from the home directory walked into ~/Library, where readdir on
TCC-protected group containers blocks forever for a daemon that cannot show
a consent prompt. Each blocked call holds a libuv threadpool thread, so a few
searches wedged every fs operation in the daemon.
- Add an excludedDiscoveryPaths option; discovery skips those paths, while
exact typed paths still resolve and browsing inside lists one level.
- Resolve rooted typed paths directly and anchor the walk under an existing
typed parent instead of walking the whole home directory.
Refs #4879
Anchor the absolutely positioned screen reader install label to its count. Without a containing block inside the carousel, the invisible label escapes the scroll container and expands the document.
* fix(opencode): return browser screenshots to OpenCode v2 agents
The v2 bridge plugin passed a Paseo tool's MCP content straight to
OpenCode. OpenCode v2 tool content is text or a file, so the image block
from browser_screenshot was not valid output: the tool call never
completed and the turn failed.
Convert image blocks to file content with a data URI and keep text
blocks as they are.
Closes#6318
* fix(opencode): show v2 tool images as images, not base64 output
With the bridge now returning screenshots to OpenCode v2 as data URI
file content, the v2 timeline copied each file's URI into the tool
call's output, so the screenshot card showed the whole base64 string.
Keep data URI images out of the tool output, leave an "[image]"
placeholder, and emit each image after the tool call as image markdown,
the way Claude and Codex tool images are shown. History replay uses the
same mapping.
---------
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
* fix(schedule): keep firing after a run that could not be recorded
A schedule whose running-run write failed stayed marked as running in
memory, so every later tick skipped it and run-once reported it as
already running until the daemon restarted. The running mark is now
cleared whenever the run ends, including when it could not be recorded,
and the next tick retries the due run.
* test(schedule): skip the unwritable-directory test as root
---------
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
* 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>
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>
* 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>
* 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>
* 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>
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>
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>
* 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>
* 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>
* 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>
* 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>
* 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>
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.
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.
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.
* 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
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.
* 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>
* 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.
* 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>
* 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
* 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>
* 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.
* 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>
* 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
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>
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.
* fix(omp): reopened chats show a compaction row instead of "Unsupported history record"
OMP stores a compaction as a top-level session entry. History replay sent
it to the unknown-record fallback, so after a daemon restart the chat
showed a "Custom message" row reading "[compaction] Unsupported history
record". Replay now maps the entry to a completed compaction row.
Closes#6206
* test(omp): assert on the replayed custom-message rows directly
* test(omp): match only the unsupported-record row after restart
---------
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
* fix(acp): terminate the provider when close requests never return
Archive and delete wait on ACPAgentSession.close(). cancel() and
unstable_closeSession() had no timeout. The ACP SDK does not reject a
pending request when the provider never answers, so close() never
reached terminateProcess. The agent was already marked archived and the
provider process stayed alive.
Both calls now use the same 2s withTimeout as probe close. The existing
catch blocks still continue into terminateProcess.
Example:
- Input: archive an ACP session whose cancel and closeSession never settle
- Before: close() waits forever and the provider process stays alive
- After: both calls time out and terminateProcess runs
* test(acp): archive close terminates a real provider that never answers session/close
Spawn a real ACP provider process over stdio that advertises session
close and never replies to it, then close the session the way archive
does. Without the request bound, close() never returns and the provider
keeps running.
Co-authored-by: Ouyang Xingyuan <galaxyroaming@qq.com>
* test(acp): move the close wait and provider cleanup into helpers
Clear the race timer once close settles, and dispose the provider
process and temp directory in afterEach.
---------
Co-authored-by: Ouyang Xingyuan <galaxyroaming@qq.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
* fix(speech): publish models only after extraction completes
* fix(speech): reinstall local speech models whose extraction was interrupted
The daemon decided whether a local speech model was installed with its own
check in speech-runtime.ts: every required file exists and is non-empty. A
model whose extraction was stopped part-way passed that check, so the daemon
never called the downloader again and dictation aborted on the truncated
file on every start.
The models module now owns "is this model installed", and the runtime asks
it. A model whose archive is still in .downloads did not finish installing,
since the archive is removed only after the model is in place, so it is
reinstalled from that archive. Extraction goes to a directory beside the
archive and moves into place once complete.
Co-authored-by: 3ae3ae <ogyshjsm@naver.com>
* fix(speech): remove the model archive before moving the model into place
* docs(speech): describe when the model archive is removed
---------
Co-authored-by: 3ae3ae <ogyshjsm@naver.com>
Co-authored-by: Bae Seongmin <83870598+3ae3ae@users.noreply.github.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
* Open Usage as a modal over the current screen
The sidebar Usage icon and summary now open Usage in the adaptive modal
(a dialog on wide layouts, a bottom sheet on compact ones) instead of
navigating to the /usage screen, so closing it leaves you where you were.
The /usage route and UsageScreen are removed.
* Reset Usage modal cards when the host changes; keep scroll test body linear
* fix(app): preserve timeline rows across virtual layout changes
Keep recent and virtual history rows under one keyed host so entering
virtual history preserves their DOM and loaded image state. Initialize the
virtual range from the mounted viewport to avoid an empty handoff commit.
Measure committed row dimensions before accepting reading geometry,
including child layout changes flushed during resize observation. Separate
row content from position updates while retaining live revision signals.
Add a regression covering row identity through virtualization and the
position of content following a growing row. Keep the existing browser
assertions unchanged.
* test(app): verify child state survives timeline virtualization
* fix(app): show PR panel activity and add-to-chat labels in the app language
The PR panel's Activity header, its empty state, every "Add to chat"
button, "Add all to chat", "Adding...", and the comment and thread menu
labels were written in the components, so they stayed English in every
other language. The sidebar status group rows also appended an English
" group" to their accessibility label. They now come from the
translation files, with copy for all nine supported languages.
Closes#6203
* test(app): check the new PR panel labels are translated in every language
---------
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
browser_type committed its text with CDP Input.insertText on the tab's
debugger. Chromium commits that text into the focused element of the
whole Paseo window, so when the user was typing in an agent composer
and the browser tab did not hold focus, the agent's text landed in the
composer instead of the page.
Insert the text with the tab's own webContents.insertText, which
commits into the tab's focused element regardless of window focus.
Fixes#6182
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
* fix(highlight): add syntax highlighting support for Vue (.vue) files
* fix(highlight): use Vue-aware mixed-language parsing for .vue SFCs
* fix(highlight): make Vue interpolation boundary quote-aware and brace-balanced
The interpolation scanner matched the closing '}}' with a plain
indexOf, so expressions containing '}}' inside strings or nested
object literals ({{ { a: 1 }}}) cut the expression short. Find the
closing delimiter only at brace depth 0, skipping strings, comments,
and regexes like the existing block scanners do.
* fix(highlight): satisfy lint complexity limits in vue parser
Split nestedLanguage into templateOverlay/directiveOverlay/scriptOverlay
and drop the unused findClosingBrace helper. Behavior unchanged.
* fix(highlight): keep an empty {{}} from breaking Vue highlighting
An empty interpolation gave the expression parser a zero-length range,
which Lezer rejects, so the whole file fell back to plain text.
Co-authored-by: Davin <128177533+Davnn1@users.noreply.github.com>
* fix(highlight): color quoted Vue directive values and nested template strings
A quoted directive value went to the expression parser with its quotes,
so it parsed as one string. A template string nested inside ${} ended at
its inner backtick, so a }} inside it closed the interpolation early.
Co-authored-by: Davin <128177533+Davnn1@users.noreply.github.com>
---------
Co-authored-by: davnn <davinvin744@gmail.com>
Co-authored-by: Davnn1 <128177533+Davnn1@users.noreply.github.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
After a daemon restart, an OMP chat replays from its session file. Three
kinds of entries that the live timeline never shows were replayed as
collapsed "Custom message" rows:
- the rule-violation reminder OMP adds after a tool call matches one of
its rules, a developer message that opens with
<system-reminder reason="rule_violation" ...>, while only the bare
<system-reminder> tag was hidden
- the ttsr_injection entry OMP records alongside that reminder
- the fileMention message an @-mentioned file adds, and the other OMP
message roles the live timeline drops
Closes#6131
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
* fix(app): translate the PR checks summary, sidebar status labels, and "Worked for" line
These were written in the components, so they stayed English in every
other language. They now come from the translation files, with copy for
all nine supported languages.
Closes#6190
* fix(app): rebuild translated labels when the app language changes
The checks summary, sidebar status groups, and turn footer now take the
translation function, so their memoized labels rebuild on a language
switch. Russian one-check group labels use singular wording.
* test(app): compare compact footer targets at sub-pixel precision
The browser reports the 44pt footer buttons as 43.99999 wide on some
runs, which fails the finger-size check on main as well.
---------
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
* fix(app): leave out an image when a copied selection starts below it
A drag that starts in the gap below an image in an assistant reply
anchored at the start of the image's rendered internals, ahead of the
hidden img, so the copy included the image's Markdown although only the
text after it was highlighted. A selection that starts inside an image
and ends past it now starts after the image.
Closes#6187
* fix(app): keep a failed image when a copy starts in its error text
A failed image shows its error message as text inside the image frame. A
selection that starts in that text selected part of the image, so it keeps
the image's Markdown. Only a selection that starts inside the frame before
any of its text starts after the image.
---------
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
* fix(opencode): agent goes idle after a v2 tool permission is denied
OpenCode 2.0.20 ends the execution when a tool permission is rejected and
reports the interruption with reason "shutdown", the same reason it uses for
an execution it will resume after a server restart. The turn tracker only
treated "shutdown" as final after a dismissed question, so a denied tool
permission left the agent running with nothing pending, and Stop waited on a
turn that could never complete.
Treat any denied request owned by this session as ending the turn when the
following interruption reports "shutdown". Denials of child-session requests
do not interrupt the parent execution, so they are excluded.
* test(opencode): denying a child session's permission keeps the v2 turn resumable
---------
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Clicking Refresh on a usage card in the context window details closed the
card within the close grace, with the pointer still on it. Refresh disables
itself while the request is pending, which drops the click's focus to the
document, and the hover card treated any focus leaving the card as a reason
to close.
Only keyboard focus keeps a hover card open, so only losing keyboard focus
now closes it. Whether focus is keyboard focus is read when it arrives,
because the element losing focus no longer matches :focus-visible during
focusout.
Closes#6176
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
The French locale was machine-translated word by word, without context:
"Back" became "Dos" (the body part), CI "Checks" became "Chèques" (bank
cheques), "Starting" became "Départ", "Connecting" became "De liaison",
and many interpolation placeholders were glued to neighboring words
("{{label}}copié", "Daemonindisponible").
Review every client-owned French string against its English source and
call site, apply one glossary across the file, and use French
typography (’, …, non-breaking space before high punctuation).
Add regression tests for the reported labels and for placeholders glued
to French words.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(app): copy an assistant image as its Markdown, with alt text
Selecting across an image in an assistant reply copied the rendered img,
which has no alt text and, for data and workspace images, points at a
preview URL. The image frame now carries its Markdown source and alt text
the same way other Markdown elements declare their copy form.
Closes#6163
* fix(app): copy an image selected on its own as its Markdown
A selection inside the rendered image left the image's copy attributes
on the frame outside the range, so nothing was copied as Markdown.
* test(app): name the image-only selection in the copy spec
---------
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
* fix(opencode): end the v2 turn when the user dismisses a question
OpenCode ends the execution when a question is dismissed but reports the
interruption with the reason "shutdown", which the turn tracker keeps open
waiting for a resume that never comes. The agent stayed running, and Stop,
reload and send were refused. A question dismissed from Paseo now lets that
interruption end the turn as canceled.
* test(opencode): cover the next message after a dismissed v2 question
---------
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
* fix(app): restore desktop screen header dragging
* fix(desktop): keep the titlebar draggable after scrolling chat
* fix(desktop): preserve global control exclusions without blocking chat headers
Keep programmatic focus containers out of the global interactive no-drag rule. Retain semantic controls everywhere, including window-owned sidebar toggles and negative-tabindex buttons. Verify both header rows and independently positioned controls with native input.
Co-authored-by: Ruichen Zhou <ruichenzhou@outlook.com>
* fix(desktop): isolate chat content from window drag regions
Electron includes scrolled interactive rectangles outside their viewport in window hit testing. Reset window regions at the chat viewport while preserving the global control exclusions for chrome and portals.
---------
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
* fix(app): close hover cards when the pointer leaves a clicked trigger
A hover card stayed open while any focus sat inside its trigger or content,
so clicking a sidebar workspace row (which focuses the row's button) pinned
the card after the pointer moved away, until Escape or a click elsewhere.
Only keyboard-visible focus now keeps the card open, which preserves the
keyboard entry path added with the shared hover card.
* fix(app): keep a hover card open when keyboard focus arrives during its close delay
Recheck keyboard focus when the close timer fires, and move the hover card e2e steps into sidebar helpers.
---------
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
* fix(app): fill the speed icon for accelerated tiers
* fix(app): highlight the Codex speed bolt when Fast or Ultrafast is selected
Codex speed moved from the Fast toggle to a Speed selector, and the
composer only colored toggle features, so the bolt stayed muted for
every speed. A select feature is now active when its selected option is
not the default, and the Codex speed takes the same yellow highlight as
the Fast toggle, on the desktop toolbar and in the compact sheet.
Closes#5939
Co-authored-by: Nurshot <85260051+Nurshot@users.noreply.github.com>
* test(app): assert the Codex speed bolt is yellow on desktop and compact
---------
Co-authored-by: Nurshot <85260051+Nurshot@users.noreply.github.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
A selection starting after an item's marker demotes that item to a paragraph.
When the item holds a code block, Turndown's HTML re-parse splits the paragraph
into several siblings, and counting siblings numbered the next item too high
("4. Done." instead of "2. Done."). Each item now carries its number before
the re-parse.
Closes#6140
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
* fix(nix): ship @getpaseo/server's main export in the desktop closure
The desktop's runtime-paths.js locates the server package root with
require.resolve("@getpaseo/server"), which resolves through the exports
map to dist/server/server/exports.js. The desktop never loads that file,
so nft doesn't trace it, and the Linux Nix desktop build fails to start
the daemon with MODULE_NOT_FOUND.
* fix(nix): retain the server resolution anchor for CLI and desktop
Run the existing Nix daemon smoke through the built CLI so package-root resolution is exercised before readiness and HTTP checks.
Co-authored-by: Callum Wong <mail@callumwong.com>
* fix(nix): ship complete terminal runtimes
Resolve runtime dependency edges at their importing modules so package links and exports survive closure tracing regardless of npm hoisting. Use the native loader's selected files and verify terminal commands and hook CLI execution through the built package.
Co-authored-by: Callum Wong <mail@callumwong.com>
* test(desktop): make packaged CLI path expectations portable
---------
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
* fix(muse): show usage only for the agent account
Usage discovery ignored session scope and exposed all remembered accounts to unrelated agents. Resolve session accounts from their own launch environment while preserving global discovery.
* test(muse): own the usage scope regression in the plugin suite
* fix(usage): scope agent reports to their own logins
Account grouping also merged fetch inputs and cache entries across agent and host queries, hiding the agent login failure behind another harness. Preserve scoped login sets, collect every host failure with source-owned harness labels, and keep refresh results within their requesting view.
* test(usage): expect harness labels in discovered accounts
* fix(app): copy a code block without its fence when the drag starts beside it
A drag that starts or ends in the gap beside a code block anchors the
selection at the edge of the neighbouring block. Only the code is
highlighted, but the range crossed the block boundary, so the copy took
the Markdown path and wrapped the code in its fence.
A selection now crosses a code block only when it selects content
outside it. Message rows the range touches without selecting anything
are dropped, and a selection whose content all lies inside one code
block copies as that code.
* fix(app): keep a selected image when copying it beside a code block
An image selected next to a code block has no text, so the selection
counted it as empty and copied only the code. Count images as selected
content so the copy keeps the image and the fence, as it did before.
---------
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
MSP view cursors request only a suffix. A fresh provider session rebuilds an empty timeline, so it must resume without a cursor to recover earlier messages. Keep gap recovery cursors in memory and omit them from durable session state.
The directory header now keeps only "Submit a plugin", next to the search
box. Building and submitting move into a "Build your own plugin" section at
the end of the page, with "Read the docs" and "Submit a plugin" buttons.
The docs link is labelled "Read the docs" because that is what it does.
The site footer gets top margin on every page; it sat too close to content.
* fix(providers): launch plugin CLIs on Windows and tolerate Muse startup
Plugin providers bypassed the core Windows launcher and the plugin host exposed an empty server SDK module. Share the launcher through the SDK and bind it in the host. Give Muse initialization its own deadline because native startup traverses session history before answering MSP.
* fix(providers): terminate Windows launcher process trees
A command-script launcher leaves the CLI behind cmd.exe. Expose forced process-tree termination in the server SDK, use it for Muse and SDK ACP teardown, and bound Muse closing so inherited streams cannot leave callers waiting forever.
* test(muse): keep platform setup outside the teardown assertion
* refactor(plugin): expose process capabilities without quoting helpers
* fix(providers): close process trees on uncorrelated protocol errors
* fix(muse): load models whose effort tiers carry descriptions
Muse 1.4.2 sends reasoningEffortVariants as described tiers
({ tier, description? }) and the complete effort list on variants, as
the bundled MSP types declare. The catalog schema had the two shapes
swapped, so any model with a described tier failed the whole model/list
parse: the model picker stayed empty and agent creation failed.
Parse both fields as declared, offer the efforts from variants, and
show Muse's tier descriptions on the matching options.
* fix(muse): leave effort descriptions out until the pickers show them
* fix(muse): keep unused catalog metadata outside validation
Described effort tiers are presentation metadata, not selectable efforts. Validating their shape blocks both discovery and session startup even though Paseo never consumes them. Normalize the complete variants list at the wire boundary and cover session readiness and selecting max.
* fix(muse): align consumed session and event wire fields
---------
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
* fix(omp): fail the turn when OMP 18.3 rejects a prompt after acknowledging it
OMP 18.3 acknowledges a prompt, then reports a rejection such as a missing
API key only as a second failed response frame for the same request id. The
JSONL RPC transport dropped any response with no pending request, so the
turn stayed running with no error.
The transport now publishes a response nothing is waiting for like any
other frame, and the OMP runtime reads a failed prompt response as the
prompt_result error later OMP releases send.
* fix(omp): drop prompt results held for an OMP process that was replaced
Request ids restart with each OMP process, so a rejection the old process
reported after its prompt timed out could fail a prompt on the new process
with the same id. Move the real-OMP rejection case into the existing OMP
real E2E suite.
* test(omp): verify late-response routing at the transport boundary
---------
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
* fix(server): keep the daemon running when its stdout reader is gone
`paseo daemon run` in the foreground hands its stdout and stderr to the
supervisor. When the program that started it closed its end, as
`paseo daemon run | head -1` does, the supervisor's next write failed
with EPIPE and nothing handled the error. The supervisor exited 1, the
worker lost its parent and exited too, and the CLI reported "Daemon
failed to start".
The supervisor now drops output nobody reads and keeps supervising the
worker. daemon.log still records everything. Other stdout and stderr
errors surface as before.
Closes#6114
* test(server): fail on unexpected daemon.log read errors and check stderr reaches the log
---------
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
* fix(omp): finish the turn when OMP's run contains a developer message
OMP 18.5 injects hidden developer messages into a run, such as the
rule-violation reminder after a tool call matches a built-in rule. The
agent_end frame then failed schema validation and was dropped, so the
turn stayed running until it was cancelled.
Closes#6104
* fix(omp): parse every OMP message role so agent_end is never dropped
OMP's RPC AgentMessage also defines pythonExecution, hookMessage,
branchSummary, compactionSummary and fileMention. A prompt that
@-mentions a file puts a fileMention message in agent_end, the frame
failed validation and was dropped, and the turn stayed running.
---------
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
OMP reports a prompt it rejects before running the agent, such as one
with no API key for the selected model, as a prompt_result with
agentInvoked false, status error, and the error message. Paseo read only
agentInvoked and ended the turn as completed, so the agent went idle with
no reply and no error. The turn now fails with OMP's message.
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
After a restart the parent's timeline is rebuilt from Claude Code's
transcript, and the Task/Agent call went through the generic tool
mapper, which has no subagent branch, so the card replayed as a bare
"Agent" with detail type "unknown". Live, the card is built from the
task protocol's declaration instead.
Replay already reads each Task call's type and description from the
parent transcript for the subagent track. Label the replayed cards
from those same facts, through the builder the live path uses.
The shared tool-call parser is left alone: a completed sub_agent card
from it would replace the action log a foreground child's card
carries live.
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
* fix(cli): exit quietly when stdout's reader is gone
When the program that launched the CLI closed its end of stdout, the
output write failed with EPIPE and nothing handled the error. Node
printed an unhandled 'error' event and exited 1, and the desktop app's
CLI passthrough could raise Electron's main-process error dialog.
runCli now exits with code 0 on EPIPE from stdout. Other stdout errors
still surface as before.
Closes#5982
* fix(cli): keep running when stdout's reader is gone
Exiting on EPIPE ended a command's own lifecycle: `paseo daemon run`
writes its ready line and then supervises the daemon child, so exiting
there left the child running without its parent. Drop output nobody
reads instead, and let every command finish with its own exit code.
* fix(cli): stop following when stdout's reader is gone
With EPIPE ignored, `paseo agent logs --follow` and `paseo agent attach`
kept running after the program reading their output closed it, until
interrupted. Both now wait through one helper that stops on SIGINT,
SIGTERM, or EPIPE on stdout, so a closed reader stops them at their next
line of output the same way Ctrl+C does.
---------
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
* fix(server): name a linked global install as the reason self-update is refused
npm 7 and later print no link flag in `npm ls --json --long`, so a global
install made with `npm install -g <dir>` or `npm link` was never detected as
linked and self-update reported that the daemon was not running from the npm
global install. Detect the link from the install directory being a symlink,
the same way npm decides it.
Closes#6086
* fix(server): fail the self-update inspection when the install path can't be read
An unreadable install path now surfaces as the update error instead of being
treated as a normal install. Tests build real install directories for both
the linked and the normal case.
---------
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
* fix(app): keep the centered new workspace form usable with the keyboard open
The centered tablet dock translated the whole form by the full keyboard
shift and reserved the keyboard twice to keep its top below the header.
On short tablet viewports such as an unfolded foldable, that left about
20pt for the setup fields and composer, which then overlapped.
Translate the centered form only by the part of the keyboard that
reaches it, and size it to fit above the keyboard.
Fixes#6012
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(app): rest the centered new workspace form on the keyboard
The keyboard shift excludes the bottom safe area, but the keyboard also
covers it. Measuring the centered form's clearance and capacity down to
the screen edge left the form one navigation-bar height under the
keyboard, hiding Import session on Android tablets.
Measure both above the bottom safe area so the form ends at the
keyboard's top edge.
Fixes#6012
Co-authored-by: Ivan Malison <IvanMalison@gmail.com>
---------
Co-authored-by: Ivan Malison <IvanMalison@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Each beta now rewrites the series' single entry to cover the previous stable
release to the release source, describing current behavior rather than adding
a per-beta entry. Promotion renames the entry to the stable heading.
When Claude Code calls its EnterPlanMode tool, it reports the new mode
through a system status message with permissionMode "plan". The Claude
provider only read the compacting status from those messages, so the
session kept the previous mode and never emitted mode_changed: the app,
`paseo inspect`, and the stored agent record all kept showing the old
mode.
The provider now records the mode carried by a status message and emits
mode_changed when it differs, sharing the bookkeeping with the init
message so the plan-approval resume mode stays the mode Claude left.
Closes#6063
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
* fix(usage): keep details hoverable and restore compact sidebar sheets
Share hover-card mechanics with workspace details so context usage can stay open while users press Refresh. Honor Retry-After for rate-limited usage requests, and let both sidebar entry points share the compact sheet.
* fix(usage): restore settings cog with a fitted popover and compact sheet
Keep shared Usage preferences in the title controls on every surface. Settings rows use the menu surface directly, avoiding the trailing margin and duplicate card border from the page layout.
* fix(app): show account usage before the first agent turn
Keep the context meter available for existing agents without context readings so account usage can be opened before a turn. Show an empty ring and an explicit no-data state until readings arrive.
* fix(usage): preserve keyboard access and hover card positioning
Context details must remain reachable by keyboard after replacing the tooltip. Keep focus within the card, restore side flipping, and dismiss detached cards when the viewport moves. Prune expired cooldowns even after credential rotation.
* chore: remove QA screenshots from the source tree
* fix(app): let hover cards claim Escape before agent shortcuts
Register hover surfaces with the shared overlay keyboard owner without moving or trapping focus on pointer hover. Component-local Escape handling ran after the global interrupt shortcut and cancelled the underlying turn.
* fix(app): reopen focused hover cards with button keys
Keep focused triggers registered with overlay arbitration after dismissal so activation keys are handled before composer shortcuts.
* fix(app): release overlay ownership when hover cards close
Closed triggers handle activation locally, so a dismissed card does not block pane Find. Open cards retain shared Escape arbitration to protect running turns.
* test(app): pin Linux keyboard identity for composer Find
* test(app): synchronize context usage hover journeys
Return focus to the composer before asserting pointer dismissal, wait for the card to reach the viewport, and park the pointer before reloading so it cannot consume the next scripted usage response early.
* fix(opencode): wait for the server to listen before opening the event stream
OpenCode can accept TCP connections before its HTTP handler is ready. Starting the event consumer at spawn lets a retry land in that window and hang without receiving server.connected.
Gate the first request on the listening promise. Use a five-second watchdog before connection readiness and abandon stalled attempts independently of fetch abort, ignoring any late records. Keep the existing thirty-second watchdog after readiness.
* fix(opencode): forward the listening gate from the agent client
The first startup fix passed the listening gate from the server manager, but the agent client factory dropped it before building the consumer. Lab logs showed four generations retrying before listening and then hitting the first-record watchdog.
Forward the gate through that factory. Exercise the client-owned manager with a real spawned process in the existing provider dispatch test, and assert that no event request arrives before listening.
#6044 enlarged the compact footer tap targets to 44x44 and also bumped the
glyphs to 24px. Keep the targets and return the glyphs to 20px, the composer
toolbar glyph size. The compact footer spec now asserts the glyph size.
* fix(providers): show custom context in expandable tool rows
Preserve custom message type and data instead of attributing extension context to the assistant. Apply the same mapping in live output and history for OMP and Pi, and honor hidden Pi messages without suppressing structured extension updates.
* fix(server): mark truncated plain-text tool content
Keep the existing timeline content budget while making omitted text visible to readers of extension context and other plain-text tools.
* fix(acp): answer open permission requests when a turn fails
When an ACP prompt failed while one of its session/request_permission
requests was still open, Paseo stopped showing the request but never
answered it, so agents that refuse new prompts until the request is
settled stayed stuck until the agent was reloaded.
A turn that ends without completing now answers its open permission
requests with cancelled, as interrupt and close already do.
Closes#6040
* test(acp): cover permission requests left open by a cancelled turn
---------
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Claude Code answers a background Agent call right away, then streams the
child's frames. Each frame re-emitted the parent's card from the sidechain
tracker after the tool-input cache had dropped the call, replacing the
settled, labeled card with a bare running "Task". The tracker now emits the
parent card only while the parent's tool call is still open.
Closes#6059
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
* website: plugin page design mockups (four directions, throwaway routes)
* website: final plugin page mockup (A directory, B browse frame, list cards, D detail)
* website: browse pages use a four-column card grid in the final plugin mockup
* feat(plugins): keep registry installs behind a daemon flag, off by default
Off restores the pre-registry behavior: bare owner/repo is GitHub shorthand and the
daemon never resolves or contacts a registry on install, update preview, or update.
Enable with pluginRegistryEnabled: true or PASEO_PLUGIN_REGISTRY_ENABLED=1.
* feat(website): ship the plugin directory, browse pages, and single-column detail
Directory: count, build/submit links, What's new, the nine categories, and Most installed
with a This week / This month / All time switch. Browse pages at /plugins/all and
/plugins/category/<slug> with a category sidebar, tabs, and a four-column grid; old
/plugins?category= links redirect. Install counts gain daily KV buckets kept 60 days.
* fix(website): keep a README's leading GitHub alert on the plugin page
* feat(website): search plugins from the directory and browse pages
Typing replaces the URL with /plugins/all?q=<term> (or the current category
with ?q=), which renders matching plugins on the server. Matching covers
name, description, ID, and author. Old /plugins?q= links redirect.
* fix(website): keep the directory's ranking window when searching
A tablet or an unfolded foldable is wide enough for the desktop layout,
so opening an agent autofocused the composer and raised the on-screen
keyboard over the conversation. Autofocus on open now also requires a
fine pointer, the same signal the app already uses for hover.
A mouse-driven window still focuses the composer when an agent opens,
and sending a message still keeps the composer focused.
Co-authored-by: Phieu Dinh <phieu@fiu.one>
* fix(acp): show context-window usage for generic ACP agents
Generic ACP providers never populated Paseo's context meter: the standard ACP
`usage_update` notification was discarded by `handleUsageUpdate` (`void
update`), while the meter only reads `lastUsage.contextWindowMaxTokens` /
`contextWindowUsedTokens`, which `usage_updated` stream events write.
Forward `size` and `used` on a `usage_updated` event for the active turn,
guarded by the same validity rules the meter applies so a provider cannot push
a window the meter rejects, and preserve the turn usage already recorded.
Verified with vitest acp-agent.test.ts (106 passed), the server typecheck, and
an isolated daemon run where a dsh agent reported
`lastUsage={contextWindowMaxTokens:1000000,contextWindowUsedTokens:13623}`.
* fix(acp): emit context-window usage only with both meter fields
Require a valid size and used together so a partial usage_update cannot
wipe the composer meter. Drive the tests through sessionUpdate instead
of the private translator.
* fix(pi,omp): open sessions whose model was removed
Importing or reopening a Pi or OMP session failed with
`Model "<id>" not found` when the session's model was no longer
configured, because the resume launch always passed `--model`.
Resume now launches on the session's own model, which Pi and OMP
replace with their default when it is gone, and switches to the
requested model afterwards only when it differs and is available.
Closes#6039
* fix(pi,omp): fall back only when the requested model is unavailable
Check the provider's model list before switching on resume, so a
failed switch to an available model reaches the caller instead of
silently keeping the session's model.
* fix(pi,omp): match a requested provider:id model on resume
Compare the parsed provider and model id against the session and
available models, so a reference written as provider:id switches
instead of being treated as unavailable.
---------
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
* fix(server): resume with last live mode, not creation-time mode
Provider-initiated mode switches (ACP current_mode_update, in-session
commands like /bypass, permission-driven plan->acceptEdits transitions)
update currentModeId/lastModeId but never reach config.modeId. On
resume, buildConfigOverrides restored the stale creation-time mode and
applyConfiguredOverrides pushed it back via set_mode — silently
downgrading the session (e.g. bypass -> acceptEdits after restart).
- buildConfigOverrides + buildStoredAgentConfig prefer lastModeId
(the last observed live mode) over config.modeId
- applyObservedMode syncs config.modeId on every provider-reported
mode change (mode_changed, model_changed runtimeInfo, and the
refreshSessionState poll) so in-memory reloadAgentSession carries
the live mode as well
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* test(server): assert config.modeId sync on mode_changed/model_changed events
Extends the config-drift stream test with a registry so both the
in-memory agent.config.modeId and the persisted record.config.modeId
are verified — covering the event paths Greptile flagged as untested.
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---------
Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* fix(app): preserve mobile sidebar scroll position when reopening
Keep hidden native overlays laid out so Android touch cancellation cannot spring retained scroll offsets through a zero scroll range. Hide and gate touch input on the UI thread to preserve panel motion during JS stalls.
* docs(app): quote sidebar replay workspace examples
* test(website): read plugin HTML without waiting for remote images
* test(website): exercise built worker in browser checks
The ordered plugin spec reproduces a no-response navigation timeout against a cold development server. Build and preview the published worker for E2E checks; preserve the original content and metadata assertions.
* fix(server): report an unknown agent as not found to CLI commands
fetch_agent_request answered an unknown agent ID with an error string, so
DaemonClient.fetchAgent threw instead of returning null. Every CLI command
that looks up one agent checks for null to report AGENT_NOT_FOUND, so
delete, stop, inspect, logs and the rest reported their generic failure
code and skipped the "paseo ls" hint.
The daemon now answers an unknown agent with a null agent and no error.
An empty or ambiguous identifier is still an error.
* test(cli): expect the not-found message agent logs now prints
* test(cli): move the unknown agent ID cases into the delete and stop suites
---------
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Directory search shares two filesystem slots across all searches. Searches
over one tree walk it in the same order, so two Add Project searches that
reach the same folder whose read never returns held both slots, and every
later directory search queued behind them: command center file search,
@-file autocomplete, and file-link lookups in unrelated folders all hung
until the read returned.
Searches waiting on the same path now share its pending filesystem call, so
one stuck folder holds one slot and the other stays free.
Refs #4879
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
* fix(server): keep workspaces available after setup config errors
* refactor(server): drop the archive dependency from worktree setup
Setup no longer archives a workspace when it fails, so the setup
workflows no longer take archiveWorkspaceRecord.
Co-authored-by: Bae Seongmin <83870598+3ae3ae@users.noreply.github.com>
* test(server): stop passing the removed archive dependency to the worktree workflow
* test(server): check paseo.json survives without comparing line endings
Git checks the fixture out with CRLF on Windows, so comparing its exact
contents failed there.
---------
Co-authored-by: 3ae3ae <ogyshjsm@naver.com>
Co-authored-by: Bae Seongmin <83870598+3ae3ae@users.noreply.github.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
* fix(app): enlarge compact sidebar footer tap targets
The compact drawer reused 28px desktop buttons, making footer actions hard to tap. Use 44px targets with larger glyphs in compact layouts while preserving desktop geometry.
* test(app): wait for compact footer tap stability
Each pending fs call holds one of libuv's four shared threadpool threads,
and a read on a hung mount, a protected macOS folder, or a huge network
directory does not return. Add Project starts a new search per keystroke
and nothing cancels the old ones, so four stuck searches took every
thread: logging stopped and `paseo project create` timed out until the
read returned or the daemon was killed.
Directory search now runs its filesystem calls through one shared limit
of two, leaving the rest of the pool to the daemon.
Refs #4879
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Leandro Ardissone <59826+lardissone@users.noreply.github.com>
The Agent SDK request never set thinking.display, so on Opus 4.7 and
later the API default of "omitted" applied and every thinking block
arrived with empty text. Paseo drops empty blocks, so Claude agents
never produced reasoning timeline items.
Closes#4342
* fix(cli): delete archived agents in bulk commands
* test(cli): cover archived agents in bulk delete from the CLI suite
The daemon-backed test imported server test utilities from a CLI test,
which the CI contract in scripts/ci-workflow.test.mjs forbids. Cover
--all and --cwd against archived agents in the runDeleteCommand unit
test instead.
* fix(cli): read every history page when bulk-deleting agents
agent delete --all and --cwd read one page of fetch_agents, so agents
past the first 200 were skipped, and fetch_agents leaves out saved
agents whose provider is unavailable. List agents from the paged agent
history, which the History view uses, so bulk delete covers both.
* fix(cli): delete an agent by ID without listing history
---------
Co-authored-by: 3ae3ae <ogyshjsm@naver.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Claude Code reports the kind of notification in `notification_type`, but
the hook parser only read `matcher` and `reason`, which Claude never sends
in Notification input. Every idle_prompt notification resolved to nothing,
so a Claude terminal waiting for the user never showed as needing input.
Closes#5845
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
paseo daemon restart gave each daemon status read one second. Status probes
every provider binary, which takes longer than that on machines with several
providers installed, so restart kept retrying until its deadline and reported
RESTART_NOT_CONFIRMED after the worker had already been replaced.
The status read now gets the remaining restart deadline. Connecting to the
replacement keeps its one-second bound, so polling a worker that is not up
yet stays responsive.
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
The Nix package copies only what scripts/trace-daemon.mjs lists. The list
named the v1 bridge bundle but not opencode/v2/bridge-plugin.bundle.mjs,
so creating an OpenCode v2 agent from the Nix package failed with
"Bundled OpenCode bridge plugin artifact is missing".
List every bridge bundle under opencode/, and add a dist test that
installs the traced closure the way nix/package.nix does and loads the
bridge plugin for each OpenCode version from it.
Closes#5926
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
* feat(usage): stream reports for live agent accounts
Resolve account scope from the running harness environment without invoking lifecycle hooks. Keep the client aggregate result while delivering report updates as each fetch settles; the app presentation follows separately.
* fix(usage): retain account scope for open agent sessions
Capture the launch environment once per session, independently of lazy child-process startup or exit. Use a session UUID for registry identity. Update remaining global discovery fixtures to the scoped SDK contract.
* test(usage): update browser fixtures for scoped report streams
* feat(usage): let a usage card show window rows without pins
UsageCard takes a required pinnable prop. When false, a window row is a
plain bar: no Pressable, hover highlight, or pin glyph. The settings
Usage list passes true.
* feat(usage): show the agent's usage in the context window popover
Reports stream into the query cache as each update arrives, for the
host list and for a new per-agent query keyed by agent. The context
window popover fetches its agent's reports while open and renders one
unpinnable card per report as it lands, a loading sentence before the
first, the request error as a sentence, and nothing on hosts without
usage reports.
* feat(usage): hide the Refresh button on popover usage cards
UsageCard takes a required refreshable prop. The popover passes false
because its content takes no pointer events, so the button could not
be pressed; the card then shows its freshness inline. The settings
Usage list keeps the button.
* feat(usage): make the sidebar Usage summary opt-in
The sidebar Usage item starts hidden on every layout, not only on
compact ones, so resolveSidebarNavItems drops its compact input. While
the summary is off, the Usage list renders window rows without pins;
saved pins stay in preferences and return when it is turned on. Specs
that read the summary now turn it on first.
* docs(glossary): describe the popover usage card and the opt-in sidebar summary
* fix(usage): show a popover request error without retrying
A daemon error for an agent's usage, such as an unknown agent, does not
heal on retry, and reopening the popover fetches again. Retrying held
the loading sentence for about seven seconds before the error.
* test(usage): cover agent usage in the context window popover
The usage fixture records agentId, streams reports held behind
promises, and can switch host support between reloads. The meter spec
walks loading, streaming, a report problem, a request error and a host
without usage reports, on desktop and compact in both themes.
* fix(usage): keep the plain context window tooltip on hosts without usage
The popover widened to fit usage cards on every host. Hosts that cannot
report usage now keep the tooltip's default padding and width, as
before, and mount no usage section.
* fix(usage): let a report's Refresh replace it in the agent's list too
Refresh wrote the refreshed report only into the host's list, so a card
rendered from an agent's list kept the old report. Both lists now sit
under one host key and Refresh replaces the report in each that holds it.
* feat(usage): open the context window details in a sheet on compact screens
Wide screens keep the tooltip. On compact screens pressing the meter opens
a content-sized bottom sheet titled Context window. Both render one
ContextWindowDetails body; only the sheet's usage cards have Refresh,
since the tooltip cannot be pressed. Pins stay off in both.
* test(usage): cover the compact context window sheet
On the compact viewport the meter opens the sheet; its cards have Refresh,
a Refresh replaces the card, and Close dismisses the sheet. The glossary
names the sheet.
* feat(ui): give tooltips and the context window sheet accessible roles
Tooltip content is role=tooltip, and the context window sheet is a dialog
named Context window, so assistive tech and tests find them by role. The
agent usage message drops its test ID; it is found by its text.
* test(usage): reshape the context window and Usage switch specs as journeys
The context window spec runs one tooltip journey and one sheet journey per
theme, with no layout branches. Its fixture script, meter actions and
report builders move to a context-window helper, and it finds the meter,
tooltip, sheet and Refresh buttons by role. The theme comes from the
system colour scheme. The Usage switch test uses named helpers for the
Usage icon and the Summary in sidebar switch.
* fix(usage): say an agent's usage request failed instead of showing older reports
A failed request keeps whatever reports the query held: those from an
earlier open, or those that streamed in before the failure. The context
window details showed them as if they were the agent's current usage.
The request error now wins over held reports.
* test(usage): let a fixture clean up the context window spec's agent
The tooltip and sheet tests no longer wrap their steps in try/finally;
a spec-local fixture seeds the agent and removes it after the test.
* fix(usage): keep a refreshed report when the list it is in finishes
Pressing Refresh on one card while another source was still streaming
put the pre-refresh report back: the finished list replaced the query's
data with the copies the request had fetched when it started. A report
now keeps whichever copy was fetched later, both when a streamed copy
arrives and when the finished list replaces the streamed one.
* fix(usage): show only the login a resumed agent runs under now
An agent resumed under another login keeps its ID, so its usage query
kept the previous login's reports: reopening the context window showed
them while the new login's reports loaded, then both side by side until
the request finished. The agent's usage is now dropped as soon as its
tooltip or sheet closes, so each open loads the login the agent runs
under at that moment.
* fix(usage): stop a closed agent usage request from writing reports
Closing the context window details cancels the agent's usage request and
drops reports still on their way, so reopening after a resume under
another login sends a new request instead of joining the old one.
Starting or stopping a script from one client sent script_status_update only
to that client, so other clients viewing the workspace kept the old status
until they reloaded. Script status is daemon state, so the WebSocket server
now publishes it to every session, and each session delivers it only to its
script_status_update subscribers. Scripts started over MCP use the same path.
Closes#5856
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Scott Woolsey Biggart <297695+swbiggart@users.noreply.github.com>
The supervisor prepended the sherpa-onnx platform package directory to the
daemon worker's loader variable (PATH, LD_LIBRARY_PATH, DYLD_LIBRARY_PATH),
so every agent and terminal inherited it. In packaged desktop builds that
directory resolves inside app.asar, and on Windows Git Bash truncates the
PATH it hands to native executables at that entry.
The local speech worker already sets the loader variable on its own
environment when it is forked, so the daemon worker does not need it.
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: hanhnt2 <56497031+hanh9898@users.noreply.github.com>
* fix(omp): load history that contains a failed Paseo browser tool result
Paseo browser tools return a structured { code, message, retryable } error in
the tool result details. The OMP result schema passed details.error through
unvalidated while the history mapper treated it as a string, so one failed
browser call threw and the whole agent timeline failed to load.
Closes#5949
* fix(omp): keep the browser tool's structured error in the tool result
Model details.error as a string or a structured { message, ... } error
instead of dropping non-string values, so the expanded tool row keeps
the error code and retryable flag, and read the failure text from the
structured message.
---------
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
* feat(plugins): install reviewed artifacts from open registries
* fix(website): build protocol before deployment checks
* fix(website): make plugin pages discoverable to search engines
Resolve missing registry entries in route loaders to return HTTP 404. Serve a registry-backed sitemap so newly listed plugins and authors are discoverable without rebuilding the website.
* feat(website): keep plugin directory unlinked for coordinated launch
Preserve direct routes and SEO while retaining the partner directory link. Cover nested Git artifacts through registry installation and pinned website commands.
* fix(website): link plugin cards by registry owner and stub the clipboard in e2e
Plugin and author URLs come from the owner segment of the plugin ID, not the author's GitHub login, so a listed plugin always resolves. The Playwright spec records clipboard writes instead of granting a permission WebKit does not have.
* fix(app): smooth chat history pagination and reserve image space
Select the range at the pending reading offset before a prepend mounts rows. Reuse committed geometry for scroll input so a layout correction cannot hide the user's movement. Reserve image dimensions before loading and measure transient mounts in the browser regression.
* fix(app): scope cached image dimensions to their source
Raw file-path aliases let one workspace supply dimensions for another. Keep file metadata tied to its daemon and workspace, and close the delayed-image test server even when setup fails.
* fix(app): retain local image context in row height estimates
Pass the rendering server and workspace through height estimation so scoped image metadata remains usable before an older row mounts.
* refactor(app): name row height estimate inputs
* fix(app): render linked pictures in PR markdown
Consume image-only headings, anchors, and picture wrappers together so splitting at the fallback image does not leave raw opening tags or discard its link.
* fix(app): preserve picture block boundaries in markdown
* fix(app): parse nested image wrappers without recursion
* test(app): account for delayed wheel input in scroll assertions
CI recorded a 480px wheel scroll delivered after its event alongside 41px of image growth below the new reader. Use the same input budget to locate the reader and account for intervening growth, while continuing to reject added viewport displacement.
* test(app): locate the scroll reader from frame movement
Use the existing platform input for editing, Find, and clipboard behavior. Exercise Mac and Linux modifier chords through the app and a real PTY, and keep pending modifier chips authoritative.
Promote the inline multi-property parameter types added by this PR to
named interfaces, per docs/coding-standards.md.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Greptile review: the browser tests patched navigator.userAgent/platform
globals to exercise mac-only key handling. Make the platform an
explicit mount option (defaulting to navigator detection) so tests
inject it directly and the runtime stops sniffing navigator per
keydown.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
xterm ignores meta+arrows, and 6.x dropped the macOS conversion of
option+arrow to ESC b/f, so cmd+arrow did nothing and option+arrow
leaked ESC[1;3C-style sequences the shell rendered as a stray "C"/"D"
plus a bell. Map the chords the way Terminal.app and iTerm2 (Natural
Text Editing) do: cmd+left/right -> ^A/^E, option+left/right ->
ESC b/f, cmd+backspace -> ^U.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(plugins): expose agent closed lifecycle event
Emit the event when a live agent reaches its closed state so plugins can release agent-scoped resources. Keep archive and close notifications distinct.
Co-authored-by: ilteoood <6383527+ilteoood@users.noreply.github.com>
* fix(plugins): finish close hooks during daemon shutdown
Wait briefly for pending lifecycle events before stopping plugins so agent close hooks can release their resources during shutdown.
Co-authored-by: ilteoood <6383527+ilteoood@users.noreply.github.com>
---------
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: ilteoood <6383527+ilteoood@users.noreply.github.com>
* fix(app): keep chat reading position stable while scrolling
Prepend restoration froze the reading offset while wheel input and delayed image measurements changed layout. Give the viewport one reading anchor, preserve concurrent user movement, and apply virtual row measurements before paint. Suspend automatic pagination while explicit message navigation settles.
* fix(app): type the reading anchor scroll intent explicitly
* fix(app): retain the reading anchor through virtualization startup
Keep the reader pinned while the first virtual range mounts. Strengthen the scroll regression detector to compare shared rows when the original anchor unmounts, and consume fixture replies only when they are emitted.
* test(app): distinguish delayed wheel input from idle scroll drift
CI frames can arrive long after their wheel events. Start idle assertions after the input driver finishes, and account for an image entered by wheel input expanding below the new reading line. Replayed all six CI captures without false positives while retaining the original 76 baseline jumps.
* test(app): bound image growth allowance to displaced reading rows
Require the entered image top to follow wheel input before attributing movement of a later row to its growth. Calibrate the detector against simultaneous viewport jumps and jumps of the growing row itself.
* test(app): clamp scroll expectations at the history boundary
* feat(app): unify Explorer tabs and refine interface styling
Give Explorer the workspace tab controls while keeping launcher choices separate from drag-and-drop support.
Upgrade Lucide and set a shared icon stroke width for consistent styling. Reduce the desktop composer bottom inset to align the action row with its corners.
* fix(app): show active Explorer tabs in foreground
The shared row coupled foreground styling to keyboard shortcut ownership. Separate those concerns so Explorer can highlight its active tab while Cmd+T remains with the workspace pane.
* fix(app): refine Explorer close controls and launch options
* test(app): respect singleton Files in Explorer plugin workflow
* test(server): trim unused setup from no-track checkout regression
* test(app): count visible main tab rows after pane moves
Swap Inter for Geist as the website sans font. FAQ +/− toggles now sit on
the content rail instead of hanging outside it, slightly larger, with
answers indented to the question text.
The website dev server crashed with "createStartHandler is not a function"
on every SSR hot reload. Vite 7's module runner races when re-evaluating
the static import chain behind TanStack Start's server handler
(TanStack/router#7285, fixed upstream in vitejs/vite#22369). The worker
entry now imports the handler at request time until the website moves to
a Vite release with that fix.
* fix(server): advertise macOS ComputerName
macOS may expose UNKNOWN through its kernel hostname when DHCP or reverse DNS does not provide one. Use the user-configured ComputerName for daemon host identity instead.
* test(server): cover macOS Hub host name
* feat(website): feature OMP, add agent icons to /agents, and use Inter
Featured agents on the homepage are now Claude Code, Codex, OpenCode, Pi,
OMP, Cursor, Muse Code, and Antigravity, driven by one list so the hero
badges, the provider cards, and the "+N more" count stay in sync. The count
previously subtracted a stale constant (5) while 7 agents were shown.
/agents moves onto the shared SiteShell like Hub and Changelog and lists
every agent as an icon and name. Icons for ACP agents are vendored from the
app's provider catalog rather than imported across packages.
The site switches to self-hosted Inter so typography no longer depends on
the visitor's OS; system-ui fell back to DejaVu Sans on many Linux setups.
* fix(website): keep vendored agent marks out of the homepage bundle
The homepage only shows the eight featured agents, which have inline icon
components, but importing AgentPageIcon pulled all 31 vendored SVGs into
its bundle. AgentPageIcon now lives in its own module used only by /agents.
The Codebuddy mark drew past its 16x16 viewBox and was clipped; the
viewBox now matches the artwork's bounds.
* feat(website): add a thanks page after downloading
Desktop and APK download buttons on /download and the homepage now open
/download/thanks, which starts the download and points people to Discord,
GitHub, Reddit, and sponsoring while it runs. The button carries the asset
name rather than the file URL, so the page resolves the URL from the
release data itself and cannot be pointed at an arbitrary link.
* fix(website): start the exact file the visitor clicked on the thanks page
The thanks page looked the file up again from an asset key and channel, so
it could start a different release than the button showed (a release
published in between, or a beta link without a beta). Download links now
carry the release file URL itself; the thanks page accepts only URLs under
this repo's GitHub releases, after URL parsing normalizes `..` segments.
The e2e test waits for the file request instead of a download event,
because WebKit reports no download event for a stubbed response.
* test(website): assert the thanks-page download event outside WebKit
* test(website): split the thanks-page download check out of the shared test
* fix(usage): derive window names and unify pin selection
Quota slots do not identify a period. Derive numeric windows and IDs from reported duration and quota scope; keep unknown lengths neutral. Resolve source defaults and explicit pins through one model, with a persisted empty selection distinct from defaults.
* fix(plugins): declare the duration helper host requirement
* fix(usage): verify persisted selections at the storage boundary
Chromium bounding boxes differ by millionths of a pixel for equal-width footer buttons. Allow less than 0.01px in width and grouped-gap comparisons while preserving alignment and Help/Settings separation checks.
* fix(usage): keep existing logins visible and explain unavailable quota
Credential discovery must preserve accounts whose login exists even when it has expired. Keep vendor failures on the discovered account and isolate discovery failures without inventing provider cards.
* test(usage): align credential route tests with account discovery
* fix(app): hide the sidebar Usage item while it has no summary
Without a summary window with data, the item fell back to a plain "Usage"
row that duplicated the footer's Usage icon. It now renders nothing, and the
footer rows leave it out so an otherwise empty rows block and its separator
disappear with it.
* fix(desktop): restore built-in usage in packaged apps
The external plugin compiler cannot traverse app.asar, and dependency packaging drops declarations needed by import validation. Ship complete built-in resources beside the archive and verify registration in the packaged desktop smoke check.
* fix(usage): restore login discovery across harnesses
Discover subscription accounts independently of agent sessions and retain each account’s logins in preference order so an unusable credential can fall back to another login for the same account.
The plugin compiler builds client code with esbuild's neutral platform,
which reads no package.json entry fields, so any dependency without an
exports map failed with Could not resolve. Read module and main for the
client target, as the server target already does through the node
platform.
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
* fix(skills): install one Codex-discoverable orchestration copy
* fix(skills): keep installed skills when agent skill roots are symlinked together
Retiring the old Codex copy deleted the skills just installed when
~/.codex/skills resolved to ~/.agents/skills or ~/.claude/skills (or the
reverse), and status reported drift forever. The Codex skill directory is
now treated as a leftover only when it is a separate directory from the
shared and Claude copies.
Co-authored-by: 3ae3ae <ogyshjsm@naver.com>
* test(e2e): expect agent skills in the shared and Claude roots only
The skills settings spec still expected every skill in ~/.codex/skills.
It now checks the two install roots and that ~/.codex/skills holds no
Paseo skill, which fails against the previous installer.
---------
Co-authored-by: 3ae3ae <ogyshjsm@naver.com>
Co-authored-by: Bae Seongmin <83870598+3ae3ae@users.noreply.github.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Tintinweb batches completions that land close together into one
subagent-notification, with the first agent at the top level and the
rest in details.others. The adapter only read the top level, so every
other agent in the group stayed running in the Subagents track, live
and after a reload.
Closes#5822
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
When a resumed Claude agent's transcript was missing, history loading
returned without a log line, and when reading or parsing the transcript
threw, the error was swallowed. Either way the agent opened with an
empty timeline and daemon.log said nothing. A missing transcript is now
logged at info with the session id, cwd and the path that was checked,
and a read or parse failure is logged as a warning with the error.
Closes#5438
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Gabriel Giordano <hello@gabrielgiordano.com>
* feat(app): streamline sidebar usage and stacked sheets
* fix(app): restore compact sidebar navigation clicks
The close button container emitted box-none through Unistyles as invalid CSS and intercepted New workspace clicks. Let React Native apply click-through behavior at the view boundary. Keep the import journey on its existing New workspace screen and assert only the visible transcript.
* fix(app): cap menu sheets at eighty percent height
Keep long sidebar usage reports recognizably sheet-sized while allowing the content to scroll. Short menu sheets retain dynamic sizing.
The relay exports map sent the import and default conditions to src/*.ts,
which the published package does not include, so any bundler resolving
@getpaseo/client failed with 'Could not resolve "@getpaseo/relay/e2ee"'.
Every condition now resolves to dist, like @getpaseo/protocol and
@getpaseo/client. The client build now builds relay first, and the Metro
workaround for compiling relay source is removed.
Fixes#4730
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
When a Codex image request fails, Codex completes the imageGeneration
item with status "failed", an empty result, and an optional failure
record. The image mapper only knew how to render a successful image, so
it returned nothing and the item vanished from the live timeline and
from reopened history. Failed items now map to a failed image_generation
tool call carrying the failure details.
Closes#5619
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: 3ae3ae <83870598+3ae3ae@users.noreply.github.com>
The composer picked the thinking level from what was chosen at launch and
fell back to the model's catalog default when nothing was chosen. A Pi agent
started without a thinking level runs on Pi's own configured default, so the
composer showed Medium while the agent ran on High. The composer now prefers
the level the running agent reports when it is one of the model's options,
the same way it already prefers the running model.
Fixes#5800
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
OpenCode checks permissions for its own tools and wraps MCP tools in an
ask, but leaves plugin tools to call ctx.ask themselves. The bridge plugin
never did, so an "ask" rule for paseo_* tools never produced a prompt and
the tool ran. The bridge now asks with the tool's catalog name before
dispatching, the same shape OpenCode uses for MCP tools. With no paseo_*
rule OpenCode's default allow applies, so nothing new prompts.
Fixes#5324
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Yuval Marcus <ymarcus93@gmail.com>
* Revert "Remove the unreleased Antigravity provider plugin (#5781)"
This reverts commit 9d9d9af993.
* fix(providers): commit plugin notices emitted during session opening
* feat(providers): restore Antigravity with full access and session warnings
* fix(providers): warn once when opening Antigravity sessions
Driver respawn retains the live transcript and unchanged permission posture. Emit the warning only when opening a session, including resume after a daemon restart.
The composer fell back to the catalog's default model whenever the agent's
model was missing from the loaded model list, so a Codex subagent started
on a model the catalog did not list yet was labeled as the default model,
with that model's thinking options.
Fixes#5791
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
* fix(server): start new worktrees at the remote tip of an origin/* base
Branching off a remote-tracking base such as origin/main used whatever
the repository had cached. Nothing on the branch-off path fetched, and the
background fetch only runs while a workspace of that repository is
observed, so a new workspace could start hours or weeks behind the remote.
Fetch just the base branch from its remote before git worktree add. The
refspec is passed on the command line so remote.<name>.fetch is untouched,
and a failed fetch falls back to the cached ref so creation still works
offline.
Co-authored-by: odoo-mvds <mv@droggol.be>
* fix(server): fall back to the cached base before the create request times out
A remote that accepts the connection and never answers held the base-ref
fetch for its 120s cap, past the 60s client request timeout, so
paseo worktree create failed and the worktree appeared later. Cap the
fetch at 15s so creation falls back to the cached ref in time.
---------
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: odoo-mvds <mv@droggol.be>
* fix(app): show default usage windows when no windows are pinned
* fix(app): show only percentages in sidebar usage summaries
* fix(client): restore usage source logos for released hosts
* fix(app): restore the labeled footer line below sidebar items
* fix(app): use History's host filter to pick the usage and plugin host
The Usage screen, the compact usage sheet and plugin screens used a separate
HostSwitcher trigger. They now use HostFilter without "All hosts", as the
label manager does, and hide it with one host, as History does.
* fix(app): save the usage host pick and show it in the sidebar summary
Picking a host on the Usage screen or usage sheet was view state: it reset when
the view closed, and the sidebar summary never read it. The pick is now a
device-local usage preference. The summary and the screen show the picked host
while it is connected, else the active workspace's host, else the first host.
The usage fixture answers by request, since the summary and the screen each
load reports and only a card's Refresh forces one.
* test(app): expect plugin footer rows above the footer bottom line
Add project moved to the bottom line with the icons, so footer rows sit above
it, spanning Add project to Settings.
* test(app): expect the saved usage host in stored settings
* test(app): let usage settings specs share reports with the sidebar summary
The sidebar summary loads reports on app start, so the settings specs answer
by request instead of by order and read percents from the settings card.
* fix(muse): make launch defaults usable and configure each agent
Disable sandboxing and trust workspaces by default so package installs and project rules work. Replace the host settings UI with validated per-agent providerOptions, preserving posture across session resume.
* docs(muse): describe shared provider options configuration
* fix(codex): label terminal writes with the command Codex started
Codex app-server reports the PTY process id on the commandExecution item,
and no longer sends the legacy exec_command_end event that carried it as
text. Record the process-to-command label from the item so terminal
writes show which command they went to.
* ci: rerun after website e2e server start timeout
* fix(codex): unwrap the shell from terminal labels like shell rows
Terminal labels now use the same command normalization as the shell row,
so a Linux wrapper such as /usr/bin/zsh -lc 'python3 -i' labels the
terminal as python3 -i.
---------
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
* refactor(providers): remove the unreleased Antigravity plugin
Remove the provider at the owner’s request because headless command approvals are unsupported. Preserve Muse, shared plugin infrastructure, and earlier editor and terminal integrations.
* test(plugins): keep builtin startup coverage generic
* fix(providers): apply configured options at session startup
Treat provider options as one opaque record, with provider-owned validation and per-agent overrides over configured defaults. Keep agent overrides separate from defaults so configuration changes apply on the next launch or resume.
* test(providers): verify provider-owned validation through Hub
* fix(providers): preserve the complete wrapped session interface
* fix(app): stack adaptive sheets across native presentations
Root-owned dialogs could not present over a sibling native dialog on iOS. Use the existing app-wide sheet stack for both native presentation forms, and release the underlying keyboard when a new sheet opens.
* test(app): verify pairing sheet autofocus
* docs(app): clarify stacked sheet journey prerequisites
* Add confirmation for new hosts
Opening a pairing link (an `#offer=` URL, a scanned QR code, or a pasted
link) now asks the user to confirm before the host is added or connected.
The prompt shows the host id, its key fingerprint, and the relay. Cancel
leaves no new host and no connection.
The host runtime owns the check: `upsertConnectionFromOfferUrl` and
`probeAndUpsertConnectionFromOfferUrl` ask a registered confirmer before
doing anything. `HostTrustPromptModal`, mounted at the app root, registers
the confirmer and renders the prompt with the shared confirm sheet. Without
a registered confirmer, pairing links are declined.
Adds a test that an offer link with the confirmation declined adds no host
and attempts no connection.
* Only ask for new hosts or changed connections
A pairing link now connects without the confirmation when it matches a
saved host exactly (same server id, key and relay). It still asks for a new
host, and for a saved host whose key or relay differs from what is saved,
with the "This link changes how you connect to this host" text.
After the user approves a link that then needs a password, the password
retry no longer asks again. The approval is an `OfferApproval` owned by one
pairing flow (one QR scan, or one open pair-link or add-host modal) and
only covers the exact offer that was approved.
Adds tests for a matching saved host, a changed key or relay, and the
password retry.
* Move host confirmation into the host runtime
The host runtime now owns the pending host confirmation and the decision to
ask. The sheet reads the pending confirmation and sends the answer; nothing
registers into a module variable.
A pairing flow is a LinkPairing the runtime hands out, so the password retry
for a confirmed link doesn't ask again without views creating or resetting
approval objects. Cancel is a result instead of an error. Pairing waits for
saved hosts to load before deciding whether to ask.
* Stop QR scanning after an attempt ends
The scan screen now keeps its workflow in a small model. When the user
cancels the host confirmation, closes the password prompt, or the attempt
fails, scanning stops until they tap Scan QR code again. Before, the code
still in front of the camera started a new attempt on the next frame.
A host confirmation answer is now tied to the request it answers, so a tap
on one sheet never answers a newer one. Relay hosts can no longer be added
through HostMutations without the confirmation, the add-host modal drops
the reset its per-open mount made redundant, and the fingerprint comment
says what the shortened form shows.
ACP agents reject mode, model, thinking, and feature writes as plain
JSON-RPC error objects. Those calls skipped the request error
normalization, so schedule runs and metadata generation recorded
"[object Object]" instead of the agent's message.
Refs #4666
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Pi 0.99 ships MCP as a built-in extension. Paseo only recognized
pi-mcp-adapter's /mcp command, so a Pi agent without the adapter started
without Paseo's MCP servers. Pi 0.99 also rejects the adapter's
--mcp-config flag, so the servers are now registered with
pi.registerMcpServer from Paseo's per-session extension. Sessions that
load pi-mcp-adapter keep the --mcp-config path.
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: ArietidsZ <69024461+ArietidsZ@users.noreply.github.com>
* test(muse): record real host protocol fixtures
* feat(muse): add built-in MSP provider
Co-authored-by: millerben95 <millerben95@users.noreply.github.com>
* fix(muse): expose provider settings in the app
The daemon registered host settings without a client contribution, leaving sandbox, network, and workspace trust unreachable. Share the definition with a settings screen and expose it through Command Center even when installed plugins are disabled.
* docs(muse): publish setup and provider support
* test: correct runtime fixtures and restart readiness
* fix(muse): hide reminder housekeeping from the timeline
* fix(muse): use the official Muse Code mark
Source: https://muse.ai/images/favicon/app-squiggle.svg
Preserve the official silhouette path verbatim. Remove its gradient definition and use currentColor, scaling the original 100-unit coordinates into the 24-unit viewBox. Black silhouette renders match pixel-for-pixel.
* fix(muse): adapt to current plugin APIs
Use the screen API introduced with sidebar plugin items. Usage reports now come from source account discovery; remove the retired agent usage-reference request and headline marker, and exercise discovery through the existing provider fixtures.
---------
Co-authored-by: millerben95 <millerben95@users.noreply.github.com>
* test(antigravity): record agy stream-json fixtures
* feat(antigravity): add Antigravity provider plugin
* fix(antigravity): address Phase 1 review findings
* test(antigravity): record parity discovery blockers
* feat(antigravity): support image prompts and daemon e2e
* docs(antigravity): document provider setup and limitations
* test(antigravity): respect Windows interrupt semantics
* docs(plugins): avoid duplicate provider override guidance
* fix(antigravity): report fresh denials and correct mode metadata
Native denial results accumulate within a driver process. Track reported occurrences until respawn so later text-only turns do not repeat notices, while new denials of the same action remain visible.
* fix(antigravity): use the official Antigravity mark
The selectable UIKit text view was a sibling of the Fabric content container, so existing chat rows kept an old width after Explorer changed the available pane. Make the container own the text view and preserve layout notifications on bounds changes. Includes a native geometry regression.
Wide native tooltips followed the desktop hover policy, and native tab chips disabled their long-press menu. Enable the existing touch actions while keeping tab navigation on completed press so horizontal scrolling does not select a tab. Keep URL-opening links from leaving a tooltip over the app on return.
Co-authored-by: Jichen <zhangjichen@oppentech.com>
Follow Pi child session files while subagents run for gotgenes, tintinweb, and nicobailon pi-subagents. gotgenes reports the child file through a runtime bridge that captures the parent's service at session start; nico async runs settle from status.json live and from the persisted subagent-notify notice on replay.
Closes#5752
Wide native panes previously followed the compact-layout gate, hiding terminal keys and skipping software-keyboard clearance. Keep bar eligibility tied to native touch while sizing rows from the available pane width.
Co-authored-by: Leandro Ardissone <lardissone@gmail.com>
Codex sends several command approval requests for one item when it
approves subcommands separately, telling them apart by approvalId. The
permission id was derived from the item id alone, so the second request
replaced the first and answering it left the first request unanswered.
Key the permission on approvalId when Codex sends one.
Closes#5615
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
* refactor(usage): remove agent-to-usage wiring
* feat(plugins): screens, sidebar header and footer items
Plugins register screens with addScreen and contribute component-based
sidebar items with addSidebarHeaderItem and addSidebarFooterItem. Items get
currentScreen, openScreen and openPopover, and render the SidebarRow and
SidebarButton kit. The footer shares the header's ordering model through the
new sidebarFooterItems preference, and Settings > Sidebar gains a Footer card.
addSurface, openSurface and addSidebarItem stay as aliases with their
settings keys and routes.
* refactor(plugins): share resolvePluginPlatform and keep header remembered-host key
Consolidate the five resolvePlatform copies into plugins/platform.ts.
Header sidebar items keep the <pluginId>/sidebar/<id> remembered-host key
so existing users keep their remembered host; footer items use
<pluginId>/sidebar-footer/<id>.
* feat(usage): global usage summary, pins and used/remaining display
- Device-local usage preferences (displayAs, pinned windows) in app settings.
- Sidebar footer usage summary: pinned windows in pin order, one per account;
opens the expanded view as a popover on desktop and a sheet on compact.
- Pin checkboxes on window rows and a used/remaining toggle shared by the
summary, popover, Usage screen and Settings usage section.
- Usage screen shows one host with a selector when several are connected.
- Extract HostSwitcher and the sidebar popover host for reuse.
* fix(usage): render each usage report as its own card
* feat(usage): row pinning, one title row for usage controls, even sidebar footer
- Window rows are the pin toggle (checkbox role, checked state); pinned rows
get a surface2 highlight, unpinned rows an interactionHighlight hover.
- Usage screen, Settings section, popover and sheet put the title left and
the host selector, used/remaining toggle and Refresh right (UsageControls).
PageLayout gains an actions slot; the menu sheet header a trailing slot.
- Sidebar footer rows share 28px height, spacing[1] gaps and one leading rail.
* fix(usage): keep row hover lighter than the pinned highlight
Hovering an unpinned window row shows the pinned surface2 at half opacity on
a separate layer, so it never reads stronger than a pinned row. Pinned rows
do not react to hover.
* fix(app): defer sidebar styles and preserve layout exports in timeline test
* fix(usage): open the Usage screen on desktop, drop the Usage footer icon, match git panel refresh
- Desktop presses on the usage summary open the Usage screen; only compact opens the sheet.
- The Usage footer icon is gone; with nothing pinned the summary renders a plain "Usage" row
in the footer row style. A stored `usage` footer preference is ignored.
- Refresh controls use the Changes panel's icon-only ToolbarButton with RotateCw.
* fix(sidebar): keep the footer icon row fixed; footer items are rows only
The footer's icon row (Hosts, Import session, Help and support, Settings) is back to main's
fixed implementation: not in Settings, not reorderable, and plugins can't add to it.
- Footer items are full-width rows between Add project and the icon row: the usage summary
and plugin `addSidebarFooterItem` rows. The Footer card in Settings lists only those rows.
- Stored `sidebarFooterItems` keys for hosts, import, help and usage are ignored.
- `SidebarButton` is removed from the plugin UI kit; a footer item renders `SidebarRow`.
* refactor(usage): call the sidebar footer item Usage
One concept, one word: the footer item is the Usage item, with key `usage`.
- Settings > Sidebar > Footer lists it as "Usage"; its preference key is `usage`.
- `usage/summary-item.tsx` becomes `usage/sidebar-item.tsx` (`UsageSidebarItem`); both states
use testID `sidebar-usage`.
- `usage/summary.ts` becomes `usage/pinned.ts` (`resolvePinnedUsage`, `PinnedUsageWindow`):
it resolves the pinned windows the item shows.
* feat(plugins): screen params, several rows per sidebar item, SidebarSeparator
openScreen takes { screenId, params }; params live in the screen route's
query so reload, history and links keep them. currentScreen carries the
params. A sidebar item may render a fragment or array of rows; a row press
anchors the popover to that row. SidebarSeparator joins the plugin UI kit.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(plugins): any screen param key; trailing presses anchor the row's popover
Screen params go in the query under a param. prefix, so keys like serverId
no longer collide with the route segments Expo Router merges into search
params. A press anywhere inside a SidebarRow, trailing content included,
makes that row the popover anchor.
Co-Authored-By: Claude <noreply@anthropic.com>
* feat(plugins): plugins title their screens
addScreen takes { id, title, Component }. title is a string or a function
of the screen's params; the screen header shows it and follows param
changes. A title function that throws or returns empty falls back to the
screen id. Legacy addSidebarItem screens keep their item's title and icon,
and the addSurface alias titles its screen with the id.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(usage): require discovery and remove unreleased agent resolver
* fix(plugins): restore legacy sidebar rows, share the item runtime, header hint presses its row
- Legacy addSidebarItem rows are their own group kind again: main's row, registered icon in
Settings, /sidebar/<id> route, host remembered per row.
- New-API items follow the host last used on any of the plugin's screens.
- /sidebar/<id> falls back to a same-id screen so pre-migration links resolve.
- params moves to PluginScreenProps; settings screens no longer receive it.
- Sidebar items, buttons and popovers share one runtime per installation, so no empty first frame.
- A header row's hint slot presses the row; labels wrap as on main.
- Skill and docs corrected.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(usage): one host resolver, sheet reuses the screen's controls, name the host
- The Usage row and screen default to the same host.
- The compact sheet carries the screen's controls, host selector included.
- The host selector shows with one host, and unavailable messages name the host.
- The row, sheet title and pinned label use the sidebar.footer.usage translation.
- Window rows read their percent and reset after the pin label.
- Drop the unread per-report cache writes and headline fixture fields.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(plugins): popovers, header buttons and composer pills own their runtime again
Only sidebar item rows use the installation's shared runtime. Popover content
disposes its runtime on unmount, so observations close with the popover.
* fix(plugins): a sidebar item releases its subscriptions when it unmounts
Each mounted sidebar item gets its own API scope over the host's client, created
during render so its first frame has content. The scope closes a microtask after
unmount (StrictMode's remount keeps it), when the item moves to another host, or
when the installation stops. Replaces the installation-wide shared runtime.
* refactor(plugins): one Paseo client per plugin; authors own their subscriptions
usePaseo() in every plugin surface, and client.paseo in setup, is the
installation's client, created with the installation in the registry. Authors
release their subscriptions in their own cleanup; plugin teardown disposes the
client and ends the rest. Nothing is created or disposed per mounted surface:
the per-surface runtimes, their boundaries, and the per-run Command Center and
slash command APIs are gone.
* refactor(plugins): one Paseo client per plugin; authors own their subscriptions
usePaseo() in every plugin surface, and client.paseo in setup, is the
installation's client, created with the installation in the registry. Authors
release their subscriptions in their own cleanup; plugin teardown disposes the
client and ends the rest. Nothing is created or disposed per mounted surface:
the per-surface runtimes, their boundaries, and the per-run Command Center and
slash command APIs are gone.
* fix(usage): show usage from released hosts
Normalize released-host usage responses at the client boundary so existing usage and pins remain available across app and daemon versions. Include account labels in legacy responses for older apps.
* fix(usage): keep the usage capability check out of the public SDK
* test(server): retry transient locks during runtime test cleanup
* test(usage): model hosts without either usage capability
---------
Co-authored-by: Claude <noreply@anthropic.com>
Branch-name, commit message, and pull request text generation run as
internal Claude agents in the workspace, so a project Stop hook that
pushes HEAD published the placeholder branch before the rename.
Closes#5742
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
paseo agent open launched Desktop with whatever string it was given,
printed "opened" and exited 0, so a bad ID moved the user to a broken
"Agent not found" tab and the calling script saw success. Look the agent
up on the daemon first, the way inspect and mode do, and open Desktop on
the ID the daemon resolved.
Closes#5721
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Codex completes a command or file change with status "declined" when its
approval is denied. The shared status normalizer did not know that word
and fell back to "running", so the denied command kept a spinner after
the turn ended.
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Bae Seongmin <83870598+3ae3ae@users.noreply.github.com>
Codex 0.156 removed thread/rollback. Rewinding a legacy-history thread
forked it and then called rollback on the fork, so the rewind failed and
left an unused fork behind. Read the Codex version from the initialize
userAgent and, when rollback is gone, fork before the target turn the
same way paginated threads already do. Older servers keep fork plus
rollback.
Closes#5616
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Bae Seongmin <83870598+3ae3ae@users.noreply.github.com>
OpenCode v2 sessions never reported context used or the model's context
limit, so the composer meter was an empty ring during a turn and vanished
when it ended. Each finished step now reports its context against the
selected model's limit.
Closes#5701
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Matt Cowger <matt@cowger.us>
* feat(codex): discover Fast and Ultrafast speeds from the catalog
Replace the model allowlist with advertised service tiers so new models and speeds need no Paseo catalog update. Normal explicitly overrides the thread tier. Desktop icon-only triggers are provider metadata; mobile selectors remain labeled.
* chore: remove QA screenshots from the change
The browser pane measures the selected element in page CSS pixels, but
capturePage takes device-independent pixels. Paseo's zoom carries into the
in-app browser, so at any zoom other than 100% the screenshot attached to
an annotation was offset and undersized. Scale the region by the guest's
zoom factor before capturing.
Closes#5690
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
OpenCode 2 reports GPT models' apply_patch tool calls under the name
patch. The OpenCode tool-call parser only recognized apply_patch, so
these calls fell through to an unknown tool card with the raw patch
text. Map patch payloads the same way as apply_patch.
Closes#5643
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Create a fresh branch from the recorded base when available, or the repository default, so archived agents remain accessible. Cover the server recovery choices and the browser restore-to-agent flow.
A Codex agent created without a thinking option ran on the configured
default effort until the daemon restarted. The resumed session never
resolved the defaults, so it showed thinking auto and sent turns with no
effort. Resolve unset model and effort for a resumed thread the same way
a new thread does.
Closes#5673
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
The Codex output schema normalizer walked every key as if it held a
schema, so a field named "properties" (or a literal default, enum, or
const value shaped like an object schema) was rewritten with invented
required and additionalProperties entries. Codex then rejected the turn
with invalid_json_schema. Walk only the keywords that hold schemas.
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
OpenCode v2 sends each question as a form field whose title is the short
header, whose description is the question, and whose custom flag allows a
typed answer. The mapper used the field key as the header and the title as
the question, and dropped custom, so the card showed only the header and
had no answer field.
Closes#5667
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
* feat(app): let users set the chat and Markdown content width
Wide and ultrawide screens were stuck with an 820px reading column. The
width is now an Appearance setting that flows through the Unistyles theme,
like the font sizes, so every content surface picks it up.
The stored value stays null until the user picks a width, so a future
change to the default reaches everyone who never customized it. Reset
writes null back.
The native composer dock keeps plain React Native styles and takes the width
inline because those styles land on Reanimated views, where theme-driven
Unistyles crash on theme changes.
* fix(app): pass the content width into the web stream viewport
The viewport read app settings itself, which required a QueryClient in every
render of the stream strategy. The agent stream view already reads settings,
so it hands the width over with the rest of the render input.
Custom prompt expansion replaced each placeholder kind in its own pass,
so text inserted by an earlier pass was expanded again ($ARGUMENTS
containing "$1"), and named values went through String.replace, which
reads "$&" and friends as replacement patterns. Match every placeholder
in one pass and insert values from a callback.
Closes#5613
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: 3ae3ae <83870598+3ae3ae@users.noreply.github.com>
The long-lived session wait hit Node’s headers timeout while OpenCode kept working. Reconcile execution events against active state and the durable log so transport observation failures do not fail healthy turns, including across event gaps and reconnects.
* fix(desktop): stop launching the CLI every second when no local daemon runs
The app polls the local daemon status once a second until it learns a
server id, and a stopped daemon has none, so the poll never ends. Each
poll ran `paseo daemon status` in a new process, which on Windows is
Paseo.exe and shows the busy cursor about once a second while the app
sits idle. The status handler now answers "stopped" in-process when the
home has no running daemon, which is what the CLI reports in that case.
* fix(desktop): report an errored daemon when its state file cannot be read
Reading the daemon instance record ran before the try in
resolveDesktopDaemonStatus, so an unreadable paseo.pid made the status
IPC reject instead of returning the errored status the CLI failure path
produced.
---------
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
On the wide settings layout, a plugin settings screen showed only its title
and the plugin's content, so getting back to the Plugins page meant finding
Plugins in the sidebar again. The compact layout already had a Back header
and project settings already had a desktop "Back to projects" link. Plugin
settings screens now render a "Back to plugins" link on desktop the same
way, including when the screen is offline or unavailable.
Closes#5518
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
OpenCode v2's edit tool takes `path` instead of `filePath`, so the
OpenCode edit schema missed it and the shared fallback dropped the
camelCase oldString/newString. Completed edits reached the timeline as
{type: "edit", filePath} with nothing to diff.
Closes#5607
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
PaseoAgentSendOptions was a hand-copied subset of the daemon client's
SendMessageOptions and dropped activeTurnBehavior, so
agents.ref(id).send(text, { activeTurnBehavior: "steer" }) and run() failed
to typecheck even though the value is forwarded to the daemon. The public
type is now the options type send() passes through, so the two cannot
drift again.
Closes#4555
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Daye <2077152374@qq.com>
A new agent's draft pane rendered the chat outline with its local tab or
draft id, so the web app asked the daemon for that id's prompt index before
the agent existed and the daemon logged Agent not found. The outline now
waits until the viewed timeline has an epoch, which only a daemon-served
agent timeline has.
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
The ring's quarter-turn lived in a Unistyles style on the react-native-svg
root. On web, Unistyles only turns styles into classes for the React Native
components its Babel plugin rewrites, so the Svg received an empty style and
the arc started at three o'clock. The rotation now lives on the progress
circle as an SVG transform, which renders the same on web and native.
Closes#5596
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
A draft composer passed no command config when it was missing a project
or a provider, which is what a running agent's composer passes. The `/`
menu then listed commands by the draft key, got "Agent not found", and
showed /clear and /exit. Drafts now always report a command target
(ready, needs-project, needs-provider), and an incomplete draft asks for
the missing choice instead of querying.
Closes#5516
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Ivan Malison <IvanMalison@gmail.com>
The example passed title, Component, and onPress straight to
addComposerPill and treated its return value as a remover function, so it
failed to typecheck against @getpaseo/plugin. It also followed agents with
agents.subscribe, a local listener that never delivers agents that exist
before the plugin loads. The example now uses the button descriptor and an
owned list subscription, like the plugin reference and local-plugin
example, and a test typechecks it against the SDK.
Closes#5517
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
An upload started while the client was reconnecting, such as right after
returning from the Android file picker, queued its request but sent the
file frames at once. The frames were dropped and the upload failed with
"Connection changed during file upload". The upload now waits on the
same connection queue as other requests before it sends anything.
Closes#5512
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Resolve retained variants against the destination model before applying a model change. Publish the effective thinking choice and refresh the provider resume handle immediately so a reload cannot restore the old selection.
Claude Code 2.1.284 introduces Sonnet 5.5 with a native 1M context
window, medium default effort, and adaptive thinking that cannot be
turned off, so it takes the same shape as Opus 5.5 in the manifest.
Sonnet 5.5 rejects `thinking: {"type": "disabled"}` with a 400, so its
entry omits supportsThinkingDisabled and its thinking options have no
off. It also takes the Sonnet family tagline, which moves Sonnet 5 to
Previous release.
The catalog gates the entry on Claude Code 2.1.284, the version that
recognizes the model, so older installs keep their current catalog.
The existing major-minor runtime matching already resolves dashed
gateway IDs such as us.anthropic.claude-sonnet-5-5-20260928-v1:0 to
the new entry without collapsing them onto Sonnet 5.
Removing a plugin provider closed its runtime and published a runtime
failure for every open session, so an idle session whose turn had already
completed gained a "Provider connection closed" error. Only a turn that
is still in flight is interrupted now; an idle session goes stale and its
next prompt reopens it from persistence.
Closes#5535
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
While a reply streams, presentation reparses only the growing last Markdown
block: the cached block text plus the new chunk. splitMarkdownBlocks drops a
trailing whitespace-only line, and only trailing "\n"s were added back, so a
reply that paused on "]\n " cached the block as "]". The next chunk " B -->"
then continued the previous line, joining two lines of a code block. Codex
streams a line break and the next line's indent as separate deltas, so its
Mermaid diagrams hit this and failed to render. History reparses the whole
text, which is why a reload showed the reply correctly.
Keep every trailing blank line the split drops on the last block.
Closes#5456
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
A provider that extends Codex runs through the registry's client wrapper,
which forwarded session listing and import but not native archive or
unarchive. Archiving such an agent left its Codex thread unarchived, so
the session stayed in Import session and could be imported again. The
wrapper now forwards both, the same as stock Codex.
Closes#5454
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
* Keep relative timestamps current in agent, schedule, and import-session rows
Agent list rows, schedule rows, and import-session rows formatted their
"5m ago" labels once at render, so an idle row kept its first label until
something else re-rendered it. They now use useTimeAgo, which rides the
same shared ticker the sidebar and desktop tabs already use.
Closes#4625
* Say "just now" for the whole first minute so fresh labels stay honest
The shared ticker wakes once a minute, so a seconds label could only go
stale. The prose and compact formatters now share one age description,
and the schedule spec anchors its clock to the schedule's creation time.
* Run every cleanup in the schedule relative-time spec and name its steps
* Keep relative labels current with focused subscriptions
* Update usage card after relative-time hook rename
* Anchor import-session age spec to fixture time
---------
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
* fix(app): show Windows file links relative to workspace
Tooltip formatting only handled matching POSIX separators, so Windows paths could remain absolute. Reuse the workspace file-path resolver for normalized relative paths.
* fix(app): preserve workspace-root link tooltips
Workspace file resolution deliberately rejects the root as an openable file, so tooltip formatting preserves its display-only dot form separately.
* fix(app): share workspace path rules for file link tooltips
* Show a host's new version after its daemon restarts
The app wrote a host's server info into the session store only when a
client was first mounted, and a reconnect reuses the same client, so
after a daemon upgrade or restart the host page and version checks kept
the old version until the app restarted.
The host runtime now projects the client's current server info into the
session store on every connection change, keeping the last known value
while disconnected. The React mount effect that duplicated this write is
removed.
Closes#5236
Co-authored-by: Leonid <127580858+bashrusakh@users.noreply.github.com>
* Keep a caller-owned test daemon home across startup retries
A failed start removed the home even with cleanup disabled, so a retry on a
taken port came back with a new serverId. The host restart spec now drives a
restartable host daemon helper that owns its port, home, and teardown.
* Remove only the test daemon dirs a failed start created
A failed attempt now removes the temp home and static dirs it created and
leaves caller-supplied ones, so cleanup: false no longer leaks per retry.
* Use function declarations in the test daemon helpers
* test(app): cover host versions across daemon disconnect and reconnect
The host settings badge retains its last known version during a transport failure; the help menu should show the replacement version after reconnect without changing another host.
---------
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Leonid <127580858+bashrusakh@users.noreply.github.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
* fix(app): open a subagent in the pane it was opened from
Opening a subagent from an agent's composer placed the new tab in the
focused pane. Clicking the subagent row does not focus its pane, so in a
split view the subagent landed in whichever pane last had focus.
Place an implicit main-location open that has a parent tab in that tab's
pane.
Refs #5433
* test(app): cover subagent split-pane placement in browser
* test(app): use browser repro for subagent pane placement
---------
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
* Report a background send_agent_prompt's started turn as running
The background branch read the target's status right after dispatch,
before the provider acknowledged the turn, so an idle target read as
idle. Wait for the run to start first, as create_agent and the app's
send path already do.
* Check the caller still gets the finish notification after a background send
* Acknowledge the held turn start explicitly in the send_agent_prompt test
* Cover immediate completion after background turn start
---------
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
A provider that extends codex and sets CODEX_HOME in its env runs Codex
against that home, but the daemon listed, expanded, and fell back to
skills from its own CODEX_HOME or ~/.codex. The / menu offered another
home's prompts and never the provider's own, and invoking one of the
provider's prompts sent the raw slash text to Codex.
Resolve the Codex home from the same environment the app-server is
launched with, and give it to the session.
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
* Show the Cursor icon for terminal profiles that run cursor-agent
The Cursor CLI binary is cursor-agent, so a profile created in settings
with that command matched no known icon and fell back to the generic
terminal icon. Map cursor-agent to the cursor icon while keeping
command matching exact.
Closes#4702
* Verify Cursor terminal profile icons in real browser
---------
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
* feat(server): run internal plugins in-process through the plugin runtime
* fix(server): harden internal plugin startup and boundary checks
* feat(usage): add plugin usage sources and Codex discovery
* fix(usage): share SDK helpers and bound source cache
* feat(usage): migrate usage fetchers to plugin sources
Serve the legacy usage RPC from discovered source reports so older apps retain the same host usage list while new clients use the source registry. Keep credential reads and normalization inside each source.
* refactor(usage): shape sources as fetch functions
Keep the plugin usage API limited to stable report and presentation helpers. Source-local parsers and timeout handling preserve credential and response parity while the cases move alongside each source.
* feat(usage): resolve reports from active session accounts
Resolve source inputs at fetch time so model and credential changes select the account each session spends. Keep the legacy discovered-account RPC for existing clients. Sync OpenCode model events outside foreground turns so usage follows model switches.
* fix usage source discovery and session report parity
* test adapter usage references through daemon RPC
* name usage reference contract across server
* refactor(usage): own usage RPCs and validate session auth
* feat(omp): resolve session usage from active OAuth credential
* fix(omp): match usage source input and valid credential fallback
* test(omp): use upstream credential database schema
* test(omp): verify live session usage references
* fix(usage): declare YAML parser and consolidate credential validation
* feat(app): show usage sources in the composer and a Usage screen
Replace the provider-matched usage in the context ring tooltip with a
composer pill driven by agent.get_usage_report, add a sidebar Usage
screen over usage.list_reports grouped by host, and move the host
settings Usage page to usage.list_reports. Hosts without
features.usageSources show an update message. Source icons come from
the report's catalog SVG. The app no longer calls provider.usage.list.
* fix(usage): declare the Paseo requirement on internal usage-source plugins
A manifest without requirements.paseo targets Paseo before 0.8, so the daemon
refused to start eight of the nine usage sources.
* fix(app): keep host connection statuses live under the React Compiler
The hook read a store version only as a memo trigger (void version); the
compiler dropped it, so pages that mounted before a host came online kept
showing it as connecting.
* fix(app): place the context ring slot before the usage pill
The ring reserves its slot before it has data. With the pill first, the empty
slot sat between the pill and the mic; now it falls in the flexible space.
* refactor(app): extract the settings page layout into PageLayout
Desktop puts the title in the page above a centered column; compact moves it
into a back header. Settings renders the same pixels through it.
* feat(app): lay out the Usage screen like a settings page
The title moves from the header bar into the page on desktop; compact gets the
settings back header, which returns home when the screen was opened by URL.
* refactor(app): drop the unused actions slot from PageLayout
* test(plugin): classify the usage source SDK entry
* fix(usage): preserve platform test contracts
* fix(settings): preserve page title test contract
* feat(usage): identify and cache reports by account ID
Keep report identity stable across input routes and token rotation, and make selective refresh operate on known account reports.
* feat(app): read usage reports through report IDs
Share report data between agent pills and host views while preserving the existing popover interaction for the next UI phase.
* fix(usage): keep account identity aligned with credentials
Read labels from local token claims and use one credential lookup for usage fetches and account identification. Bound cached profile identities across token rotation.
* fix(usage): isolate failure IDs and prune expired reports
Failure reports live outside the account-key namespace, and cache writes discard entries past the five-minute TTL.
* refactor(app): share the compact time-ago prose formatter
Move the tab tooltip's "3m" -> "3m ago" wording into utils/time so the
usage card can reuse it.
* feat(usage): show when each report was fetched
The usage card reads fetchedAt as "Updated 3m ago": a tooltip on the
card header where hover exists (web, desktop), printed in the card
footer on native and compact. Shared by the pill popover and the Usage
screen.
* feat(usage): open the pill popover from cache and refresh on demand
Opening the popover no longer forces a vendor fetch; it shows the cached
report. A Refresh control force-refreshes only this report
(usage.list_reports with its single ID and forceRefresh), shows
Refreshing... while it runs, keeps the previous report visible, and says
"Unable to refresh usage" if the request fails.
* feat(app): let ToolbarButton show a tooltip other than its label
An icon action can now say a status in its tooltip ("Updated 3m ago")
while keeping its label for accessibility.
* feat(usage): refresh a report from its card header
Refresh is an icon button at the right edge of every usage card header,
in the pill popover and on the Usage screen. It force-refreshes only that
report, writes the result to the report's cache and its host list, shows
a spinner and disables itself while pending, and prints "Unable to
refresh usage" in the card on failure. Its tooltip carries the
freshness; without hover the freshness stays in the card footer. The
status indicator sits just left of the button, and the popover's
separate refresh row is gone.
* fix(server): include internal plugin assets in built output
* feat(server): built-in plugins in /plugins (#5544)
* feat(server): load compiled built-in Codex plugin
* test(server): guard built-in plugin loading and installs
* test(server): verify built-in plugins from dist in CI
* refactor(server): migrate remaining usage sources to built-in plugins
* test(server): guard all packaged usage source registrations
* docs: describe usage validation across plugin hosts
* test(ci): include built-in plugins in routing contract
* test(server): import moved Codex usage source
* fix(server): retain ACP updates received with session creation
Rewound Codex forks receive the same provider and runtime MCP config as thread start and resume. Covers paginated and legacy rewind paths.\n\nCloses #4542\nCloses #3205\n\nCo-authored-by: zbaibg <zbai29@wisc.edu>
Hosted Hub replaces its automatic trial with a real Free plan, so the
pricing section becomes three columns: Self-hosted, Free, and Pro. Both
hosted columns render entirely from `GET /api/billing/plans` — name,
price, the `included` figures, and the catalog's feature prose — because
those figures are derived from the entitlement template the instance
enforces, so nothing on this page can drift from the limit a customer
actually gets.
The parser requires both slugs and the new `included` object and throws
on anything else. The page already degrades to "temporarily unavailable"
when the loader throws, so a strict boundary costs a fallback we have
rather than a wrong number we would ship. This is also why the change
must not merge before the Hub side deploys: against today's API the
section renders that fallback.
Public docs follow the two Hub changes: Free replaces the trial and
points at /hub for the figures Stripe metadata now owns; sign-in lands on
Home with its four-step checklist; `hub.execute` gets the grant command
it needs; and trigger authoring documents that the agent is validated
against the target daemon at save, which needs that daemon connected and
accepts Claude, Codex, and OpenCode only.
* fix(opencode): reconnect v2 sessions after helper exits
A helper exit aborted the session lifetime, so every later prompt failed even though OpenCode can serve the same native session from another helper. Keep the session open, fail the active turn, and acquire and configure the current helper on the next OpenCode operation.
* fix(opencode): forget exited v2 helper generation
A dead shared helper could remain selected as the current generation. Clear that selection at exit so the first lazy reconnect acquires a live helper.
* feat(auth): authenticate daemon clients through hello
Give local CLI and desktop clients a per-run credential while remote clients send the host password inside the protocol. Keep relay passwordless admission for this mobile release so existing saved hosts keep working until the follow-up enforcement release.
Move password recovery for saved hosts into host settings and keep pairing credentials in the add flow. Include isolated web QA evidence for both flows.
* fix(auth): preserve Hub bootstrap and local credential expectations
Hub-owned sockets already carry admission; keep their hello handling synchronous through session bootstrap while resolving credentials asynchronously for ordinary sockets. Keep the resolver out of lightweight configuration imports.
Update CLI expectations for same-home credentials, let a rejected connection yield to another usable connection, and clear pairing secrets when add-flow targets change.
* fix(pairing): retain passwords across edits to the same target
Track the last valid pairing host while a URI is edited. This preserves an entered password for whitespace and partial edits but clears it when a different host becomes the target.
* test(auth): align local credential checks across platforms
Expect the CLI to authenticate through the credential in its selected home, and verify POSIX file modes only where Node exposes them.
* feat(app): move host password into a settings row with a modal
The host page rendered an always-visible password form above Name on every
host. Replace it with a Password row under a Security section below
Appearance; tapping it opens a modal with Save and, when a password is saved,
Clear. The connection-error callout keeps its guidance and gains a Set
password button that opens the same modal. Drop the unused
pairing.hostPassword body/reopen/saveError strings.
* chore: remove committed QA screenshots
QA evidence stays out of the repository; the PR body describes it in words.
* feat(app): keep the host password out of the host page
The saved password is connection data, entered only when a host is added. A
Password setting on the host page reads as the daemon's own password, so the
row, modal, and callout button are gone. A rejected host shows the daemon's
reason with guidance to remove the host and add it again with the password.
useHostMutations no longer exposes setHostPassword; relay imports still set it
inside the runtime.
* feat(app): show host connection errors in an error alert
HostConnectionError renders the existing Alert with the connection error as
its title. Auth rejections add the re-add guidance as the description; other
errors show the title only. The Overview and Connections pages share it.
* style(app): soften alert borders to half-opacity accent
* feat(app): size alerts on the button scale
Alert takes a size prop (xs/sm/md/lg, default md) whose geometry lives next to
the button's in control-geometry: one font size for title and description, the
button icon size centered on the first text line, button padding and radius,
and a one-line alert as tall as the button of the same size. Consumers pick
sm among settings rows, cards, panels, and modals, and md when the alert is
the whole page or sheet state.
* style(app): roomier, rounder alerts with a tight title block
Alert sm padding returns to the original 12/16 and every size is at least
borderRadius.xl. Title and description sit a small gap apart with no line
height override. The icon slot carries a zero-width text strut in the alert's
font so the icon centers on the first line for titled, title-only, and
description-only alerts.
* refactor(app): align the alert icon with a centered lead row
Drop the zero-width strut. The icon and the alert's first text (title, else
description) share one row centered on each other; the description and actions
sit below, indented by the icon slot width plus gap so they start at the lead
text's left edge.
* refactor(app): drop the unused Alert icon override
* refactor(server): give Pi extension handling a registry
* feat(server): map Pi task extensions to Paseo task lists
* feat(server): map Pi subagent extensions to the Subagents track
* refactor(server): hydrate Pi child sessions asynchronously through the extension host
Child session JSONL reads blocked the daemon during live events and replay. The extension host now prepares immediate upserts and bounded async timeline hydration; replay awaits it, and closed sessions discard it. Preserve claimed notification text so completion details remain visible in the parent conversation.
* feat(server): present Pi question extensions as one question form
Keep the extension questionnaire in one Paseo permission while Pi walks its RPC dialogs. The host defers an early dialog until the form is answered, then replies to each dialog using the collected choices.
* fix(server): satisfy Pi extension start hook contract
The server build checks the session interface more strictly than the fast typecheck. Return the optional permission explicitly so the adapter satisfies the published extension contract.
* docs: document Pi extension adapters
* chore(server): format Pi subagent captures
These real RPC fixtures were added before the formatter ran over the captured JSON. Normalize whitespace so repository format checks pass without changing the captured data.
* test(server): resolve Pi child fixtures as local paths on Windows
File URL pathnames retain a leading slash before a Windows drive letter. Convert fixture URLs to filesystem paths and escape those paths when inserting them into captured JSON, so child timeline assertions exercise the real file on every CI platform.
* refactor(server): remove Oh My Pi handling from the Pi provider
* fix(server): keep Pi extension adapter failures out of core session handling
* fix(server): bound Pi child-session hydration during history replay
* fix(opencode): support v2 while preserving v1 provider behavior
* fix(opencode): target v2.0.7, keeping v1 support
OpenCode 2.0.4 removed the /api/health endpoint and the plugin
await-activation endpoint, and moved form reply/cancel under the session
namespace. The v2 adapter was pinned to the 2.0.3 SDK, so any 2.0.4+
binary failed at helper-server startup with an opaque HTTP 404.
- Pin @opencode/client to 2.0.7 and require v2 >= 2.0.4 at runtime
selection, with a clear error for older binaries.
- Use server.info for helper-server readiness.
- Move form reply/cancel/list to session.form and send the permission
decision field instead of reply.
- Replace plugin.awaitActivation with a plugin.list readiness wait. A cold
location registers its config-derived commands, skills, and providers
asynchronously; an empty inventory means it is still warming.
- Send session.command.name, which replaced command, and drop the removed
SkillInfo.slash field.
- Update the v2 test harness, unit tests, dependency lockfile, CI matrix,
and provider docs to 2.0.7.
* fix(opencode): target 2.0.10 and address v2 review findings
* test(opencode): colocate v2 suites with their modules
Greptile's review flagged that the v2 tests lived in top-level
opencode-agent.*.test.ts files and imported the readiness helper, v2
client, harness, and API types from a distance. Move each v2 suite beside
the module it describes and leave the top-level files to the v1 public
path:
- opencode/v2/agent.test.ts: session lifecycle, structured output,
resume, reconciliation, interrupt/replacement, helper isolation.
- opencode/v2/readiness.test.ts: location readiness deadline behavior.
- opencode/v2/permissions.test.ts: question and approval routing.
- opencode/v2/configuration.test.ts: permissionRules, feature flags, and
helper-isolation decisions, exercised directly through the module.
- opencode/v2/mapping.test.ts: model and mode normalization.
- opencode/runtime-client.test.ts: runtime version selection.
The v1 recovery, permission-action, and catalog-deadline suites stay in
place. Test counts are preserved, with deeper per-module coverage for
configuration.
* test(opencode): split v2 local E2E by behavior and add harness helpers
The re-review flagged the v2 scenarios in opencode-bridge.local.e2e.test.ts
for inline event filtering, permission-kind branching, and polling in test
bodies. Move stream, permission, and subagent coordination behind helpers
in opencode/test-utils/v2-local-e2e-helpers.ts, and split the session
scenarios into opencode-v2.local.e2e.test.ts so each file covers one
behavior:
- opencode-bridge.local.e2e.test.ts: process sharing, shell.env scoping,
the native tool bridge, and runtime version selection.
- opencode-v2.local.e2e.test.ts: session lifecycle, structured output,
streaming, approvals, MCP preapproval, stop/replacement, rewind and
subagents, commands, and skills/attachments.
Each helper waits on or answers the provider directly, so the tests state
domain intent and assert the collected result instead of polling or
branching inline. The CI-selected "versioned runtime" case stays in the
bridge file, and the live approvals and stop/replacement scenarios were
re-run green through the new helpers.
* fix(ci): preserve required server checks across OpenCode versions
Run v1 and v2 integration coverage sequentially so branch protection keeps its existing static check names.
* fix(agents): load startup history before registering sessions
A failed history stream while adding a runtime notice must close the unregistered session instead of leaving an initializing agent behind.
* fix(agents): limit startup history before buffering it
Apply the existing timeline content limits to each incoming history event so raw cumulative tool output is not retained until registration completes.
* fix(opencode): resolve Windows executables from platform packages
Recent v1 npm releases place opencode.exe in the architecture dependency rather than the CLI bin directory. Resolve that dependency from the CLI package while retaining support for the older layout.
* refactor(opencode): name public runtime option types
* test(opencode): let the spec own both runtime versions
Install each version in an isolated temporary directory and select it through provider configuration. Restore the CI workflow unchanged so version coverage is owned by the spec rather than workflow orchestration.
* test(opencode): launch native Windows binaries in isolated specs
Avoid npm command wrappers retaining locked executable files after helper shutdown during temporary installation cleanup.
* fix(opencode): preserve the legacy path when adding v2 support
Fall back to the existing client when version probing is inconclusive. Load v2 code and its bridge plugin only after positive v2 selection, keep v1 sessions undecorated, and restore original v1 discovery errors.
* fix(opencode): retry inconclusive version selection
A failed or unrecognized version probe previously cached the legacy fallback, pinning an upgraded binary to the v1 API until client recreation. Retry detection on the next operation while keeping the legacy path available.
* fix(opencode): release legacy runtime after inconclusive probe
Retryable version detection can clear the selected client after falling back to v1. Track whether the legacy client was selected so shutdown still releases its server without touching an unused v1 manager during a v2-only run.
* test(opencode): assert full runtime notice history
* fix(opencode): restore notice and retry behavior after rebase
---------
Co-authored-by: Jonathan karras <6307688+karrots@users.noreply.github.com>
Co-authored-by: Dhruv Singal <dhruvsingalabc@gmail.com>
A provider that extends codex with its own OPENAI_BASE_URL starts its
threads under its own Codex model provider id. Codex's thread/list returns
only the model provider its config selects unless the request names
others, and the import scan named none, so those sessions were missing
from the import list. After switching the default provider in the Codex
config, stock Codex listed them instead and imported them onto itself.
Ask Codex for the custom provider's own threads when listing its
importable sessions. Stock Codex keeps its current listing.
Closes#5381
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
* fix(app): keep restored workspaces during directory refresh
Capture workspace maps and cursors from one transaction baseline so cache hydration cannot pair an empty map with a current cursor. Reject legacy directory checkpoints to recover caches already persisted with missing workspaces.
* test(app): clarify workspace cache recovery flow
A failed write to daemon.log (for example ENOSPC on a full disk) made the
supervisor's rotating log stream emit an unhandled 'error' event, which
killed the supervisor; the worker then exited on lost supervisor liveness.
The supervisor now treats daemon.log as best effort: a failed stream is
reported once on stderr, dropped, and reopened on the next log line, so
logging resumes once the file is writable again.
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Opening an archived ACP agent whose worktree was removed spawned the
provider in the missing directory. The session spawn had no listener for
the child's error event, so Node's ENOENT became an uncaught exception
and the daemon restarted, then crashed again when the client retried.
The session spawn now rejects initialization on a spawn error, the same
way the provider probe already did, and both share one helper.
Closes#5435
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: qinkangdeid <qinkangdeidd@gmail.com>
A provider that extends claude and sets CLAUDE_CONFIG_DIR in its env
writes transcripts and settings.json under that directory, but the
daemon looked them up under its own CLAUDE_CONFIG_DIR or ~/.claude.
Agents on that provider reopened with an empty timeline after a daemon
restart, their sessions were missing from the import list, and models
from its settings.json were missing from the catalog.
Resolve the config directory from the same environment the provider is
launched with, and drop the test-only configDir client option.
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
OpenCode adds text to user messages on its own behalf (continue prompts after
compaction or a subtask, MCP resource contents, plugin prompts) and flags that
text `synthetic: true`. Its own UI hides it. Paseo built user bubbles from every
text part, so reopening an OpenCode chat showed those messages as if the user
had typed them, and appended synthetic text to the user's own message.
Closes#5425
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Pi's navigateTree without a summary only moves the session's leaf in
memory and appends nothing to the session file. Pi reopens a session at
the file's last entry, so after a daemon restart the resumed chat showed
the messages the rewind had removed.
After navigating, the Paseo extension now appends a paseo_rewind custom
entry on the new branch. Custom entries carry no model context, and the
file's last entry now sits on the rewound branch, so Pi reopens there.
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Draft slash commands are cached per provider and checkout with no expiry, so a skill from the previous branch stayed listed (or a new branch's skill stayed hidden) until the app restarted. A pushed checkout status that reports a different branch now drops that checkout's cached draft commands, and the next / menu fetches them again.
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
* List a generic ACP agent's slash commands in a new agent's / menu
Generic ACP agents advertise slash commands with available_commands_update
after session/new. The new-agent / menu opens a short-lived session and asks
for its commands right after session/new, so it always got an empty list,
which the app keeps for the draft. Wait for the first batch, bounded by the
existing initial-commands timeout, as the Cursor, Kiro, and TRAE clients do.
Closes#4759
* Keep the generic ACP command test independent of scheduling
* Wait for agent state writes before removing MCP test temp dirs
The blocking send_agent_prompt timeout test removed its temp dir while
AgentManager background tasks and AgentStorage writes were still in
flight, which intermittently failed cleanup with ENOTEMPTY on Linux CI.
Flush both before removing the dir in the tests that use real storage.
---------
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
* Notify a caller once when it prompts a child that is still running
create_agent and a background send_agent_prompt each armed their own
finish notification for the same child and caller. When the second
prompt replaces the child's running turn, the child never goes idle in
between, so its one finish fired both and the caller got the same
notification twice. Arming again for a child and caller now replaces
the earlier notification.
Closes#5397
* Declare the notification test helper as a function
---------
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
* Keep the model row's profile action out of the row button on web
On web the model row renders as a <button>, and the create/edit profile
action inside it rendered a second <button>, so React logged "<button>
cannot be a descendant of <button>" for every row. The action now sits
beside the row button inside the same hover boundary, positioned over a
slot the row reserves, so layout and hover reveal are unchanged.
Closes#5385
* Check the create-profile action leaves the composer's model unchanged
---------
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
* Run agents on another daemon from inside an agent session
paseo run sent PASEO_AGENT_ID as the caller to whichever daemon --host or
--home pointed at. That id names an agent on the daemon that launched the
shell, so another daemon rejected the run with "Caller agent <id> not
found". The run now claims the caller only when the target daemon has
that agent, and otherwise runs as a top-level agent there.
Closes#5313
* Keep the caller when the agent lookup loses its connection
Only the daemon's answer that it has no such agent drops the caller. A
lost or timed-out connection now fails the run instead of creating a
top-level agent on the caller's own daemon.
---------
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
* Open OSC 8 terminal hyperlinks like plain URLs
Clicking a link a CLI printed with OSC 8 (gh, npm, ls --hyperlink)
showed xterm's "Do you want to navigate to" prompt and then opened a
bare Paseo window, while the same URL printed as plain text opened in
the system browser. The terminal runtime now gives xterm a link handler
that sends OSC 8 links to the same external opener as plain URLs.
Closes#4768
Co-authored-by: liujin0506 <liujin0506@users.noreply.github.com>
* Name the hyperlink and line writes in the terminal link test
---------
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: liujin0506 <liujin0506@users.noreply.github.com>
* Rewind a Pi chat to the message you pick after an earlier rewind
After a rewind, Pi's session file keeps the abandoned branch. Paseo
captured user entry ids from the whole file but replayed history from
the current branch, pairing them by position. Each abandoned user
message shifted later rows onto an earlier entry id, so rewinding a
replayed row went back further than the selected message.
Capture user entries from the same compaction-aware branch Pi replays,
so the nth capture is the nth replayed user message.
* Assert Pi entry captures as data with named test helpers
* Name the fields of Pi user entry test fixtures
* Rewind a Pi row whose entry compaction summarized away
The capture fed both replay pairing and the rewind target lookup. Narrowing
it to the context branch broke rewinding a live row from before /compact,
because that row's entry is off the branch. The extension now reports the
session's user entries for the lookup apart from the branch entries replay
pairs with.
---------
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
* fix(omp): preserve described select options
* test(omp): assert exact option arrays instead of toMatchObject
toMatchObject ignores extra/missing keys, so the described-select and
malformed-metadata tests passed even with option-index misalignment or
an unfiltered whitespace-only description. Switched to toStrictEqual
on the options array so both are actually caught.
* fix(app): scroll the theme list when plugin themes overflow the window
The theme menu lists every plugin-contributed theme but did not opt into
the menu surface's scrollable mode, so its height was never capped to the
window. A theme pack with dozens of entries pushed the lower themes off
screen with no way to reach them.
* test(app): scroll the theme menu through a named helper
---------
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
The host settings Update and Restart buttons read the worker's status
before sending the request, with a 1500 ms timeout. The daemon answers
status only after probing each provider CLI with --version, which may
take up to 2 s per provider. On a host where one provider CLI starts
slowly, the pre-check always timed out and the request was never sent.
Read the status with the client's default request timeout, the same
full status read the supervisor restart test already exercises.
Closes#5367
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
* Explain that daemon set-password needs a terminal when stdin is piped
The password prompt only resolves on a keypress from a terminal. With
stdin piped or redirected it never settled, so Node exited 13 with an
unsettled top-level await warning and no message. The command now fails
with PASSWORD_TTY_REQUIRED and points at PASEO_PASSWORD.
Closes#5350
* Assert the set-password terminal error message
---------
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
* fix(server): notify the caller when a blocking send_agent_prompt outlasts its wait
An agent-scoped send_agent_prompt with background: false only armed the
finish notification for background sends. When the 30s wait timed out,
the tool returned "still running" and nothing was armed, so the child's
result never reached the caller. The wait now reports whether it timed
out, and a timed-out wait on a still-running agent arms the same
notification a background send gets and says so in guidance. A blocking
send that finishes inside the wait still returns its result without a
notification.
* refactor(server): declare the finish-notification helper as a function
---------
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
A Pi agent created without a model took the first entry of Pi's model
list, which ignores defaultProvider/defaultModel in Pi's settings. The Pi
catalog now marks the model a fresh Pi session reports in get_state as
the default, so the daemon's default-model fallback picks it.
Closes#4918
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
A reply sent last Friday at 12:02 PM, read on Friday at 11:47 AM, was
labelled "Friday 12:02 PM" because the weekday window counted 24-hour
periods, and that reply was just under seven of them old. Count calendar
days instead, so the weekday form covers only the previous six days and
today's weekday a week back shows its date.
Closes#5225
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
* Reconnect OpenCode agents to the new server after the old one exits
When the OpenCode server process exits, Paseo starts the next one on a
new port, but existing sessions kept their client bound to the old port
and failed every turn with ECONNREFUSED until the agent was reloaded.
A session now marks its connection dead when the server exits and
connects to the current server before its next turn, rewind, history
read, mode or command lookup.
Closes#5307
* Settle the exited OpenCode server's events before reconnecting
A queued exit from the old server could otherwise fail a turn started on
the new one.
* Re-add a reconnected OpenCode session's MCP servers on the new server
reconnect() reset mcpConfigured but left mcpSetupPromise holding the
first server's resolved setup, so the next turn awaited it and never
called mcp.add on the new server. Track MCP setup with one field that
reconnect clears.
* Re-run CI after an unrelated macOS file-observation flake
* Re-run CI after an unrelated website WebKit timeout
---------
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
readPersistedConfig, used by paseo daemon config, status, pair, and
set-password, parsed and validated config.json without the error
wrapping loadPersistedConfig applies, so an invalid file surfaced as a
bare SyntaxError or ZodError. Both readers now share one parse step
that names the file and, for schema errors, the field.
Closes#5328
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
* fix(daemon): report the local daemon's server id when a password is set
Since #4575, `paseo daemon status` only learned the server id from an
authenticated connection. The desktop app runs that command without the
daemon password, so a password-protected desktop daemon reported no server
id, the app treated it as remote, and hid Open in editor.
The worker now announces its server id with readiness and the supervisor
publishes it in the instance record next to the listen address, so status
reports the running daemon's identity from local state.
Closes#5300
* fix(cli): omit serverId from status until a daemon publishes one
* chore: rerun CI after macOS file observation flake
---------
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
`paseo daemon start` runs the supervisor with its output discarded, and the
supervisor opens its log only after config.json loads and the PID lock is
held. A failure before that point went to stderr only, so the launcher
pointed at a daemon.log that was never written and showed no cause.
Startup failures now also go to the log path the launcher reports, so the
error message tails the real cause.
Closes#5327
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
* fix(server): discover ANTHROPIC_DEFAULT_FABLE_MODEL from Claude settings
* Retrigger CI after website dev server startup timeout
* test(server): assert the built-in Fable entry through the fetched catalog
---------
Co-authored-by: Noah <noahguerin20@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
* Reject missing and non-directory paths for local create_workspace
The create_workspace MCP tool with isolation local persisted a workspace
and project for a path that does not exist or is a regular file. The
app's workspace create request already rejects those paths with
"Directory not found"; the tool now applies the same check before
provisioning.
* Make the local create_workspace e2e assertions path-format independent
* Rethrow filesystem errors other than a missing path in local create_workspace
* Express the local create_workspace e2e test through daemon and workspace helpers
* Clean up the offline MCP daemon when its startup fails
---------
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
* Keep checked options and the Other answer together on multi-select questions
On a multi-select question card, typing into Other cleared the checked
options, and checking an option after typing dropped the Other text from
state while the field kept showing it, so submit sent the labels alone.
Multi-select now keeps both and submits the checked labels followed by
the Other text, joined with ", ". Single-select still replaces one with
the other, and now clears the Other field on screen when it does.
Closes#4370
Co-authored-by: Jared <77949461+ThePharmer@users.noreply.github.com>
* Query the question card test by role and accessible name
---------
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Jared <77949461+ThePharmer@users.noreply.github.com>
* fix(server): keep original file names for chat uploads
Uploads replaced every character outside ASCII letters, digits, dot,
dash, and space with an underscore, so CJK, Korean, accented, and
parenthesized names reached disk and the agent as underscores.
Replace only characters no file system accepts in a file name, and
shorten names past 255 bytes while keeping the extension.
* test(server): make upload separator test independent of the daemon OS
---------
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
* Load config.json saved with a UTF-8 byte order mark
Windows Notepad saves UTF-8 with a BOM. Both config readers passed the
text straight to JSON.parse, so the daemon refused to start and CLI
config commands failed with "Unexpected token".
Closes#4683
* Strip the config BOM with an anchored replace
---------
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
A password-protected daemon that rejects the CLI is running, so the
start hint was the wrong remedy for AUTH_REQUIRED and AUTH_FAILED.
Fixes#4508
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
A supervisor killed between creating paseo.pid and writing its contents
leaves an empty file. Every later start, status, and stop then failed
with "Cannot read daemon state", so the daemon stayed down until someone
deleted the file by hand.
An empty lock names no owner, so after the existing read retries it now
reads as no lock, and acquiring the lock removes it before creating a new
one. A lock with unparseable contents still refuses, since it may belong
to a supervisor this version cannot read.
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
The permit result items stored the request id already shortened to eight
characters for the table, so JSON, YAML, and quiet output serialized that
display value. Every request id starts with "permission-", so every row
came out as "permissi" and could not be passed back to permit allow.
The items now keep the full id and only the table column shortens it.
Closes#4732
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
* Start the daemon when the schedules directory holds a file that is not a schedule
One unreadable .json file in the schedules directory rejected
ScheduleStore.list(), which ScheduleService.start() awaits during daemon
bootstrap, so the daemon never started. The store now skips a file it
cannot read as a schedule and logs its path once, the way agent storage
treats an invalid agent record.
Closes#4745
* Skip only schedule files with invalid content
A schedule file that cannot be read now fails the listing as before,
instead of being skipped as if it were not a schedule. An invalid file
is reported again when it reappears after being removed or fixed.
* Retrigger CI after an unrelated macOS observation-test flake
---------
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
* test(server): wait for repository observation before asserting ref refresh
The first checkout facts read can begin before the metadata watcher registers. The test sent an event through an optional watcher lookup at that point and silently dropped it, so a slow setup left only the initial snapshot. Hold the first read to reproduce the ordering and wait for observation setup and the initial refresh before emitting the ref event.
* test(server): isolate ref refresh from background fetch
An asynchronous background fetch could also schedule a structural refresh or populate the known-ref set. Give this test an empty fetch result and wait for that fetch to complete so the second facts read proves the injected watcher event caused it.
* test(server): require the registered repository watcher in the assertion
* Keep the plugin IPC channel open until provider closes report
closeProviderConnection removed the connection from the registry before
awaiting the provider's close, so shutdown's sweep could not see a close
already in flight. With a provider whose close does real work, the
subprocess finished shutting down and called process.disconnect() while
that close was still running, and the trailing provider.closed send hit a
closed channel and took the subprocess down with an unhandled
ERR_IPC_CHANNEL_CLOSED.
Keep the connection registered until its close has reported, and share
one close promise per connection, so shutdown waits for an in-flight
close instead of disconnecting underneath it. The close now also owns
reporting its own outcome, so it never rejects into teardown.
* Harden the reload teardown test for Windows and clean shutdown
Normalize line endings before patching the example provider, so the
fixture also builds from a CRLF checkout. Wait for the in-flight turn and
archive the agent before stopping the daemon, so teardown no longer
rejects a pending provider request into an unhandled rejection.
* Verify plugin reload teardown and reject sends during close
* Synchronize plugin reload teardown test with close lifecycle
---------
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
* fix(server): let Hub title created workspaces
Agent creation can provision a workspace under hub.execute, but a separate title request required workspace.manage. Permit the same execution authority for title changes and their responses, and lock the Hub request contract with socket and permission tests.
* test(server): keep Hub title repro free of conditional flow
* fix(server): stop re-deriving path state for every scanned directory
Directory suggestion scans were CPU-bound in path bookkeeping rather than
in reading the tree. A 20,000-entry scan of a 197,000-directory home took
6.9s, of which readdir accounted for 50ms; the rest went to repeated path
normalization.
Three redundant passes are removed:
- Discovery re-derived Git-ignore state by walking every ancestor of every
entry. Traversal only descends through entries that already passed that
check, so only the entry itself can be newly ignored.
- Discovery re-derived root containment for every child. Only a symlink can
resolve outside the tree being walked; every other child is its parent's
path plus a name, and the parent was already proved inside.
- The directory listing cache swept itself for expired keys on every miss
once it reached its cap, an O(cache size) pass per directory read. Reads
already reject stale entries by expiry and by directory metadata.
The same scan now takes 1.1s. The new test expresses the budget against a
plain readdir walk of the same tree, so it means the same thing on CI as on
a developer machine: the ratio was 12x and is now under 3x.
Refs #4879
* test(server): skip the scan-cost budget on Windows
The tree it needs is deeper than MAX_PATH allows, and the directory
listing cache it measures is disabled on Windows because directory
metadata cannot validate it there.
* test(server): count containment derivations instead of timing the scan
The previous budget compared wall-clock scan time against a readdir walk
of the same tree. packages/server/AGENTS.md requires tests to be
deterministic, and a ratio between an I/O-bound walk and a CPU-bound scan
moves with filesystem caching and runner load.
isPathInsideRoot now counts its calls behind start/stopPathContainmentMetrics,
matching the existing git command metrics. Scanning a symlink-free tree of
320 entries derived containment 1,760 times before this branch and derives
it none now, which is the defect stated exactly rather than inferred from a
stopwatch.
A second test covers the other direction: a symlink that leaves the root is
still checked, and still excluded from results.
* fix(server): keep the Git-ignore ancestor walk for symlinked entries
A symlink resolves to a path whose ancestors were never checked during
traversal, so a link into an ignored directory was discovered. Named
children keep the single-entry check.
* test(server): declare the scan helper as a function
---------
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Capture modified Backspace shortcuts in the editor while keeping bare Backspace as the erase-last-combo key. Cover the reported Mac chord, its Windows Ctrl equivalent, and the erase behavior in the desktop renderer suite.
Closes#5215
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
* fix(server): back off degraded git polling on an unchanged workspace
When the recursive watcher declines a repository (over 250,000 entries on
macOS/Windows, or any watcher setup failure), WorkspaceGit falls back to
polling. That fallback rescheduled itself every 5 seconds for as long as the
workspace existed, with no client or agent involved. On a repository large
enough to defeat the watcher each tick is an expensive `git status`, so the
daemon held about a core indefinitely.
Double the gap after every tick whose workspace snapshot came back identical,
up to a 60-second ceiling, and reset to the 5-second base on the first change.
Both degraded loops — working tree and repository metadata — share the rule.
Explicit reads still force a refresh, so the ceiling only bounds how long an
edit made outside Paseo stays undiscovered.
Fixes#5102
* test(server): cover repository-metadata degraded backoff, document failed-tick semantics
* test(server): drive the recovery-refresh assertion off the fake clock, not waitFor
* test(server): declare metadata poll counter as a function
Custom messages may precede the prompt user message during an active OMP turn. Treating them as completion terminalized the turn while the provider continued working.
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Expose Claude Agent SDK's documented extraArgs map through providerOptions so Claude-specific flags such as --chrome reach the CLI.
Document boolean and valued flags, link the upstream contract, and validate through schema tests, the public session interface, and a real Claude launch.
* fix(server): create new branches without an upstream
A new branch Paseo creates in place ran `git checkout -b <new> <base>`,
which lets git decide the upstream. Git sets `origin/<base>` whenever the
base is a remote-tracking ref (what the default-branch resolver returns
when the local default branch is absent) or when `branch.autoSetupMerge`
is `inherit`. Every later push reads that upstream, so the work lands on
the default branch: `git push -u origin <branch>` under
`push.default=tracking` pushes to `main`, and Paseo's own push resolves
`branch.<name>.merge` and pushes `HEAD:refs/heads/main`.
Pass `--no-track`, the same promise the worktree path already makes with
`git worktree add -b <branch> --no-track <base>`.
* test(server): read the upstream with for-each-ref so a git failure cannot read as absent
* test(server): use a function declaration for the clone fixture git runner
---------
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
* fix(opencode): set permission rules on the OpenCode session
OpenCode stores permission rules on the session and its prompt_async
request schema has no permission field, so rules built from provider
options and Hub tool grants were dropped on every turn: an agent
configured with external_directory: allow still asked before reading
outside its worktree.
Send the rules where OpenCode reads them — on session.create, and on
session.update when resuming a session Paseo did not just create — and
stop attaching them to prompts. A resume that cannot apply its rules now
fails instead of running under a more permissive policy than configured.
The real-server test is taken from Gurvan Campion's #5295, which reached
the same diagnosis independently.
Co-authored-by: Gurvan Campion <32047986+gurvancampion@users.noreply.github.com>
* test(opencode): use function declarations for the permission test helpers
---------
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Gurvan Campion <32047986+gurvancampion@users.noreply.github.com>
Observe provider response rejection before transport acceptance completes, while preserving the original promise and errors returned to callers. Cover early failure, send rejection, closure, cancellation, and successful retries through the public provider interface.
Closes#4781.
* feat(website): present company sponsorship as support, not ad space
The homepage showed four empty "Sponsor / $500 a month" boxes under a
"New" badge, right below a note about staying independent. They read as
ad inventory for sale. The price now lives only on /sponsor, next to the
checkout. The homepage shows a Sponsors row only once a company has
sponsored, and the READMEs link to /sponsor instead of straight to
Stripe checkout.
* feat(website): drop subtitles from the sponsor options
Each option carried a line like "From $5 a month" or "with an invoice" that tried to set it apart, but the differences are not real: GitHub Sponsors invoices too and any channel takes small amounts. Showing the names lets people pick the platform they already use.
Refresh Git exclusions when ignored directories appear after subscription and bound Linux and native classification work. Preserve tracked-file notifications, ignore-update barriers, and watcher recovery.
Fixes#4558. Supersedes #4808 by @Marcus172; this change incorporates six commits originally authored by Marek Rom and adds verified follow-up fixes. The independent session-context leak is fixed by #5208. Combined isolated daemon verification passed.
Co-authored-by: Marek Rom <marek.marcus.rom@gmail.com>
* Stop a replica cache read from spinning when the store rejects its writes
A replica cache read waits for accepted commits to reach the row store
before it answers, and retries until the store agrees. When the store
rejects the write deterministically, that retry never makes progress: the
read loops on flush and read with no delay, burning a core and never
returning. The read now fails closed on a rejected write, so callers fall
back to the daemon instead of the loop saturating the renderer.
* Fail a read closed only when its own host has unwritten changes
A rejected write carries whatever was pending across every host, so
giving up the read on any rejection hid stored rows from hosts that had
nothing pending. A host whose rows are already in the store stays
readable while another host's write keeps failing.
* Bound the read count in the storage fake instead of the test body
---------
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Task subagent frames stream on the same query as the conversation, and
rememberTranscriptProgress recorded any assistant frame as the current turn's
rewind anchor. When a turn's last assistant frame came from a subagent, the
anchor held a uuid that lives on the subagent's sidechain rather than on the
session's own chain, so forkSession rejected it with "Message <uuid> not found
in session" and every later rewind in that conversation failed.
Skip frames that carry a parent_tool_use_id when recording anchors. The
persisted history path already skips sidechain entries; the live stream now
applies the same rule.
Closes#5284
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
The pane-focus defaults carry `editable: false` so that Cmd+Shift+Arrow
keeps selecting text in a field instead of moving pane focus. That guard
describes the default combo, not the action, but `buildEffectiveBindings`
carried it onto a user's override: rebinding focus-pane to Alt+H/J/K/L in
Settings produced a shortcut that fired everywhere except the composer,
and since switching panes focuses that pane's composer, navigation
stopped after one hop.
Drop the guard when an override replaces the combo, the same way the
default display keys are dropped. The default Cmd+Shift+Arrow still
yields to text selection.
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
* Clear the committed timeline when an agent refresh rehydrates from disk
Refreshing a loaded agent reloads it with `rehydrateFromDisk`, which wipes
the in-memory timeline so provider history can be re-streamed. It left the
committed timeline in place, and that is what `getTimelineRows` reads, so
every refresh appended a second full copy of the history: N refreshes, N
copies of every message.
Wipe both timelines on that path, the way the force-hydrate path already
does, so the re-streamed history replaces the old rows instead of stacking
on them.
* Replace the committed timeline from the replay instead of wiping it first
Deleting the committed rows on the reload path meant a history replay that
failed halfway left the agent with no transcript, and a retry appended a
fresh replay to whatever partial rows it had written.
Collect the whole replay before touching either store, then drop the rows a
previous hydration committed and write the new ones. A failed stream now
leaves the committed timeline exactly as it was, and this covers the replay
that runs when an agent is loaded after a daemon restart, which duplicated
the same way a refresh did.
---------
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
A turn that dies before the model emits its first token leaves its rewind
anchor without an assistant message id. resolveConversationRewindTarget read
only the anchor immediately before the target, so that one turn made every
later rewind in the conversation throw "assistant response id was not
observed" for good.
Such a turn carries no conversation state, so walk back to the most recent
turn that did answer and fork there, falling back to a fresh session when
none did.
The rewind SDK now reaches the session as an option so the regression test
can drive revertConversation without forking a real Claude session.
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
* Start an ACP session when a stored feature value no longer applies
Replaying stored config onto a new ACP session already tolerates a model
the session cannot select, warning and keeping the provider default.
Feature values were the exception: they were written unconditionally, and
either Paseo's own "does not expose ACP feature" guard or the provider's
-32602 rejection escaped out of initializeNewSession, so no agent was
created.
Cursor's `fast` is a per-model option, so switching a draft from a
fast-capable model to one without a fast variant left a stored value the
new session cannot take, and agent creation failed.
Apply stored feature values best-effort at session start, matching the
model path. A user-initiated setFeature still reports the failure.
Co-authored-by: gengjiawen <technicalcute@gmail.com>
* Tolerate a provider's invalid-params only after a model switch
A stale option snapshot is only possible when the replay switched the
model, since the model write answers with an empty response. Without a
switch the options came straight from session/new, so a rejected write
is a real failure the user needs to see.
---------
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: gengjiawen <technicalcute@gmail.com>
The Codex Fast allowlist covered only gpt-6-astra in the GPT-6 family, so
selecting GPT-6 Sol or GPT-6 Luna published no fast_mode feature and the
composer had no Fast control to render. OpenAI documents Fast for Astra,
Sol, and Luna, and the app server's model/list advertises the fast speed
tier for all three.
Add both model IDs to the allowlist and to the parameterized feature test
covering the toggle, the restore path, and the serviceTier sent on turn
start.
The same change was proposed independently in #5220 and #5268.
Closes#5271
Co-authored-by: Ivan Malison <IvanMalison@gmail.com>
Co-authored-by: basilkhowaja <bk08432@st.habib.edu.pk>
A recorded sequence like Ctrl+K Ctrl+J never fired. Pressing a modifier
emits a keydown for the modifier key itself before the combo that holds
it, so a chord waiting on its second step saw a bare `Control` first.
That keydown matches no combo, and the resolver's fall-through reset the
chord to step 0, leaving the following Ctrl+J to resolve as a first step
and match nothing. A second step with no modifier was unaffected, which
is why Ctrl+K J worked.
A modifier keydown is the prefix of a combo, never a combo of its own, so
the resolver now ignores it and leaves the chord where it is. The capture
path already held that rule in `keyboardEventToComboString`; this exports
the predicate behind it rather than adding a second notion of what a
modifier key is.
Closes#5265
### Linked issue
Closes#4821
### Type of change
- [x] Bug fix
### Reasoning
A key sequence recorded in Settings, for example `Ctrl+K` then `J`, silently did nothing whenever the app re-rendered between the two keys. Resizing the window, toggling the sidebar, or an agent turn starting or finishing is enough, so a rebound sequence looks broken at random and the second key lands in the composer as text.
The global `keydown`/`keyup`/`blur`/`visibilitychange` listeners, and on desktop the `browser-shortcut-input` subscription, were registered by an effect that depended on eighteen render-scoped values. One of them, `useActiveWorkspaceSelection()`, returns a new object every render, so the effect tore down and re-subscribed on every render of `AppContainer`. Its cleanup resets the in-progress sequence, so the second key was resolved from step 0, matched nothing, and fell through as ordinary input.
The key handlers now read the latest render through `useStableEvent`, so the listener effect re-subscribes only when shortcuts are enabled or disabled. Clearing held badge modifiers when the workspace-index jump binding changes moved to its own effect keyed on that modifier. The cleanup still resets the sequence, which is now correct, because it only runs when shortcuts genuinely go away.
### Goals
- A recorded key sequence completes across a root re-render between its keys.
- The global shortcut listeners are registered once while shortcuts are enabled, instead of on every render.
- A regression spec that fails on the current `main` and passes with this change.
### Non-goals
- `useActiveWorkspaceSelection()` still returns a new object per render. This PR stops the shortcut listeners from depending on that identity, it does not change the hook.
- A sequence whose second step also carries a modifier (`Ctrl+K` `Ctrl+J`) is still cancelled when the modifier is re-pressed, because the bare `Ctrl` keydown does not match the next step. That is a separate defect and is not fixed here.
- The Electron `browser-shortcut-input` path is unchanged in shape and was not exercised in a desktop build.
### QA
New spec `packages/app/e2e/browser/keyboard-shortcut-sequence.spec.ts`. It seeds `Ctrl+K J` as the "Toggle command center" binding, opens a workspace, and presses the sequence twice: once with nothing in between, once with a viewport resize settled over two frames between the keys.
Before, on `main`, run twice:
```
✘ multi-key shortcut sequences › a sequence completes when the window resizes between its keys
expect(getByTestId('command-center-panel')).toBeVisible() failed
```
The control assertion in the same test passed both times, so the sequence itself works and only the re-render breaks it. Instrumenting the hook locally showed the mechanism directly:
```
key Control -> chord step 0
key K -> chord step 1
effect cleanup at chord step 1 <- the resize re-render
effect setup; changed deps: activeWorkspaceSelection
key j -> chord step 0, match null
```
After, the same spec passes, and `activeWorkspaceSelection` was the only dependency that changed on those renders.
Also run locally on this branch:
- `packages/app` unit suite: 603 files, 5319 tests, all passing.
- Playwright `browser` project, keyboard and command-center specs: `keyboard-shortcut-sequence`, `empty-pane-keyboard`, `changes-shortcut-placement`, `sidebar-workspace-pin-shortcut`, `workspace-archive-shortcut`, `command-center-agent-controls`. 17 passed.
- `npm run typecheck --workspace=@getpaseo/app`, `oxlint`, and `oxfmt --check` on the changed files: clean.
Web only, in Chromium on Linux. Keyboard shortcuts are off on mobile, and the desktop build was not exercised.
### Supersedes
- [PR #4834 — fix(app): keep multi-key shortcuts alive across root re-renders](https://github.com/getpaseo/paseo/pull/4834) — same fix for the same bug. I diagnosed #4821 independently, reached the same cause, and built on that commit, so its author is credited with a `Co-authored-by` trailer. This PR replaces its spec with one that drives the app through the keyboard only, without patching `window.addEventListener` to count registrations.
### Checklist
- [x] One focused change
- [x] `npm run typecheck` passes
- [x] `npm run lint` passes
- [x] `npm run format` passes
- [x] QA evidence
- [x] Tests added or updated where it made sense
Co-authored-by: Ivan Malison <IvanMalison@gmail.com>
### Linked issue
Closes#5247
### Type of change
- [x] Bug fix
- [ ] New feature
- [ ] Enhancement
- [ ] Refactor
- [ ] Docs
### Reasoning
A user whose MiniMax account has no active token plan opened the Usage page and saw a red **Error** badge on the MiniMax card with a block of raw JSON as its body: `[{"expected":"array","code":"invalid_type","path":["model_remains"],...}]`.
MiniMax reports application-level failures inside an HTTP 200 body — `base_resp.status_code` is non-zero and `model_remains` comes back as `null`. Two things went wrong with that response:
- `MiniMaxQuotaResponseSchema` declared `model_remains` as `z.array(...).optional()`, which admits a *missing* key but not `null`, so `.parse()` threw on a perfectly ordinary no-subscription response.
- `fetchUsage()` only checked `res.ok`, so an API-level error returned with HTTP 200 was never inspected in the first place.
The rejection propagated to `ProviderUsageService`, which stores `reason.message` as the card's error text. For a `ZodError`, that message *is* the serialized issue list — which is how the JSON blob reached the card.
This accepts `null` for `model_remains`, parses `base_resp`, and returns the plain unavailable result when the status code is non-zero, matching how a failed HTTP response is already handled in the same method. The API's own `status_msg` goes to the debug log rather than to the card: an account without a token plan is a normal state, not an error, so it should read as "Unavailable" like an unconfigured provider does — not as a red Error.
### Goals
- A MiniMax account with no active token plan renders as **Unavailable** with no error text.
- `model_remains: null` is accepted wherever it appears, with or without a `base_resp`.
- Application-level errors carried in an HTTP 200 body are detected rather than treated as success.
- Accounts that do have a plan keep rendering their interval and weekly windows unchanged.
### Non-goals
- **Generic error rendering in the Usage card.** The issue also asks that structured errors (ZodError and friends) be mapped to a concise message in the UI. That changes what every provider's card shows on schema drift, so it is a design decision for the maintainer rather than part of this fix. With the producer corrected, no ZodError arises on this path; if another provider's response shape drifts, raw issue JSON can still reach the card. Left open for the maintainer.
- Surfacing MiniMax's `status_msg` in the UI. It is logged at debug instead, so the card does not turn red for a normal no-subscription account.
### QA
Reproduced and verified through the real `ProviderUsageService` with the exact payload from the report, in `packages/server/src/services/quota-fetcher/service.test.ts`.
**Before** — `reports MiniMax accounts with no active token plan as unavailable` fails, and the failure output is the reported symptom verbatim:
```
- Expected + Received
- "status": "unavailable", + "status": "error",
- "error": null, + "error": "[
+ {
+ \"expected\": \"array\",
+ \"code\": \"invalid_type\",
+ \"path\": [ \"model_remains\" ],
+ \"message\": \"Invalid input: expected array, received null\"
+ }
+ ]",
```
**After** — the full quota-fetcher suite passes: `Tests 71 passed (71)`.
Three tests were added, two of which are red without the provider change (confirmed by stashing only `minimax.ts`):
- `reports MiniMax accounts with no active token plan as unavailable` — the reported response (`model_remains: null` + `base_resp` 2062) → `status: "unavailable"`, `error: null`.
- `reports a null MiniMax token plan as unavailable even without base_resp` — `model_remains: null` alone.
- `still reads MiniMax windows when base_resp reports success` — guards against the new status check rejecting healthy accounts.
Also run locally: `npm run typecheck --workspace=@getpaseo/server`, `npx oxlint packages/server/src/services/quota-fetcher/`, `npx oxfmt --check packages/server/src/services/quota-fetcher/` — all clean.
No UI change, so no screenshots: with the provider fixed, the card takes its existing "Unavailable" rendering, the same one an unconfigured provider already gets.
### Checklist
- [x] Plugin changes follow the SDK import boundaries (if applicable)
- [x] One focused change
- [x] `npm run typecheck` passes
- [x] `npm run lint` passes
- [x] `npm run format` passes
- [x] QA evidence
- [x] Tests added or updated where it made sense
## Supersedes
- [PR #5252 — fix(quota): handle MiniMax accounts with no active token plan (#5247)](https://github.com/getpaseo/paseo/pull/5252) — Same diagnosis and same reported workflow. That PR returns `status: "error"` carrying MiniMax's `status_msg`, which keeps the red Error badge on the card for an account that is simply unsubscribed; this branch returns the plain unavailable result the issue asks for. It also adds cross-provider `ZodError` summarizing in `ProviderUsageService`, which is the design decision left as a non-goal above.
Co-authored-by: Matteo Pietro Dazzi <ilteoood@gmail.com>
* Close agents before stopping the plugins that serve them
A clean daemon shutdown stopped every plugin before it closed any agent.
Stopping a plugin withdraws its provider registrations, which pushes an
empty registration set through the agent provider registry, shuts the
plugin provider client down, and closes each still-open session with
`connectionClosed()`. That is reported as `session.runtime_failed` and
translated to `turn_failed`, so an agent whose last turn had completed
successfully was persisted with `lastError: "Provider connection closed"`
and came back after a restart carrying a failure it never had.
Stop tracking plugin provider registrations first so plugin teardown can
no longer withdraw a provider from under an open agent, and stop the
plugins themselves after `closeAllAgents()` and the provider runtime
shutdown have already made every session terminal. Closing the sessions
while their plugin is still running also means `session.close` now
reaches the provider.
Fixes#4877
* Bound each agent close so a silent provider cannot stall shutdown
Closing an agent asks its provider to close the session and waits for the
answer, and that request is only rejected when the provider runtime shuts
down. With agents now closing before the runtime does, a provider that
never answers `session.close` would leave `closeAllAgents()` waiting with
nothing left to unblock it.
Give each close a deadline. Whatever outlives it is abandoned and logged,
and the provider runtime shutdown that runs next rejects the request that
was still pending.
Also type the test fixture's `send` input as `ProviderInput`.
* Move the shutdown tests into the plugin lifecycle suite
Both tests exercise plugin lifecycle against a real daemon, which
lifecycle.e2e.test.ts already covers, so they go there rather than into a
suite of their own.
Drop the wall-clock assertion on how long stop() took. What the test
establishes is that stop() returns at all, and the suite timeout is what
enforces that; measuring elapsed time only added a second bound that a
loaded machine could trip.
---------
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
* fix(acp): give daemon-spawned terminals the agent's launch environment
A terminal the daemon opens for an ACP agent is a sibling of the agent
process, not a child, so it inherits nothing from it. createTerminal built
its environment from the requested terminal env alone, while the transport
spawn a hundred lines down already overlays the session's launchEnv. The
terminal therefore ran without PASEO_AGENT_ID or PASEO_AGENT_CWD, so
`paseo heartbeat create` refused to run and `paseo run` lost the caller,
creating child agents with no parent. When the daemon itself was started
from inside another Paseo agent, the terminal saw that agent's id instead.
Overlay launchEnv beneath the requested terminal env in both the argv and
the single-string shell branch, matching the transport spawn's precedence.
Fixes#5016
* test(acp): take createSession options as an object
The helper had grown a second optional positional parameter, making
callers read createSession(undefined, launchEnv).
---------
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
* Dismiss an Android bottom sheet with Back instead of the screen
Bottom sheets render through a portal inside the app's own view tree
rather than a native modal window, so Android offered Back to the
navigator underneath them. On the model selector that meant one Back
press left the chat and the app, with the sheet still open.
`IsolatedBottomSheetModal` now claims `hardwareBackPress` for as long as
the sheet is on screen and dismisses itself instead. Listeners are
offered the press newest-first, so a stacked sheet answers before the one
below it, and Back falls back through to the navigator once no sheet is
left.
* Move the Back press onto the sheet visibility tracker
The first version held the press in `IsolatedBottomSheetModal` with its own
`isOnScreen` state and a merged ref, duplicating the phase the visibility
tracker already keeps, and it could only be tested through JSDOM with
`vi.mock` and `vi.hoisted`, which docs/testing.md rules out.
The tracker already owns presentation state and the sheet controller, so it
takes a `BackPressSource` port and holds the press for exactly the window it
is in the `presented` phase. `IsolatedBottomSheetModal` goes back to what it
was on main. Every sheet call site reaches the tracker through
`useIsolatedBottomSheetVisibility`, so coverage is unchanged.
The real source registers nothing off Android, where there is no such press
and `react-native-web`'s `BackHandler` logs an error on subscribe.
The test is now a plain unit test against a fake adapter, with no mocks,
no JSDOM, and no component mounting.
---------
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Pressing Stop on an OMP turn could land in the thread as a failed turn
with OMP's interrupt text as the error, because OMP ends the turn with a
terminal response that carries "Interrupted by user" in errorMessage and
"aborted" in stopReason, and completeTurn classified any errorMessage as
a failure.
A terminal response whose stop reason is aborted now ends the turn as
turn_canceled and terminalizes the turn's in-flight tool calls and
subagents, matching what interrupt() already emits when it wins the race
with the provider's teardown.
A provider-idle state check that resolves after the turn already settled
no longer completes that turn, so a stop can no longer emit a second
lifecycle event behind the cancellation.
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Claude Code expands a slash command only when it is the last content
block of the user message. buildAgentPrompt orders blocks as chat
history, typed text, images, then rendered attachments, so a "/command"
sent alongside a pasted issue link or a screenshot reached the model as
literal text: the command never ran, and the model either called the
Skill tool on its own or answered without it.
toSdkUserMessage now moves the typed block to the end when it parses as
a slash command. Attachments rendered as text always carry a mimeType,
so a quoted "/command" inside chat history is never mistaken for the
invocation. Message order is unchanged when the typed text is not a
slash command.
Co-authored-by: paseo-bot[bot] <paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Joe Corkerton <joecorkerton@users.noreply.github.com>
A Codex mode preset named its approvals reviewer only for Auto-review.
Every other mode left the field off the thread/start and turn/start
request, so the Codex app-server fell back to `approvals_reviewer` from
the user's global config, or to whatever reviewer the live thread was
already using. Default Permissions and Read-only then behaved like
Auto-review: escalations were approved by Codex, with no approval card
and no notification.
The reviewer belongs to the Paseo mode, so the preset now always names
it and the request always carries it. An agent with no Paseo mode still
inherits the Codex configuration, unchanged.
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: HMWCS <121323913+HMWCS@users.noreply.github.com>
* fix(server): keep a workspace's placement when its directory is gone
Resolving a workspace read the checkout at its cwd and reconciled the
recorded placement against it. A git read answers "not a checkout" for a
plain directory and for a directory that is not there, so an archived
worktree whose directory had been removed came back as kind "directory"
with branch, worktreeRoot and mainRepoRoot null.
That rewrite happens before the import operation runs, and the failure
path only rolls back a workspace the import created, so a failed
re-import left the record erased. Workspace recovery can only recreate a
missing directory while the record still says "worktree" with a branch,
so the archived worktree stopped being restorable from Paseo and
reported workspace_directory_missing instead.
Placement is now reconciled only against a directory that is there. The
absence is resolved once, where the workspace directory is read, and
reconcileWorkspacePlacement refuses to rewrite from it, so the active
refresh path is covered by the same rule. The workspace reconciliation
pass already skipped missing directories for this reason.
Closes#5111
* fix(server): treat a directory lost mid-read as an absence
A worktree removed or unmounted while the git read is in flight makes
that read report "not a checkout", which erased the placement the same
way a directory that was already gone did. The read now counts as
evidence only while the directory is there on both sides of it.
---------
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
* Let Stop settle an agent whose Pi or OMP runtime has exited
AgentSession.interrupt() is documented to reject "only when foreground
ownership is still uncertain", and to be a successful no-op when the
session is already idle. The Pi and OMP sessions broke that: interrupt()
issues `abort` (and `clear_queue` for Pi) over the JSONL RPC transport,
and once the child process is gone every request rejects with
"<name> process is closed".
AgentManager reads that rejection as "the provider still owns the turn",
returns `refused`, and leaves the tracked run in place. The user gets
"Cannot stop agent <id> because its active run cancellation was not
acknowledged", the agent stays pinned at `running`, and pressing Stop
again repeats it forever — only a daemon restart clears it.
A dead runtime owns nothing: no turn is running and no prompt is queued,
which is exactly the postcondition abort and clear_queue promise. Route
both through JsonlRpcProcess.requestStopWork, which resolves when the
process is gone and still surfaces real failures from a live process.
The check covers the process dying while the request is in flight, so a
race cannot reintroduce the wedge.
* Require an observed child exit before treating stop work as done
`disposed` marks the transport finished, not the child gone: `close()`
disposes before termination completes, a spawn error disposes without
touching the child, and a child can outlive SIGKILL. Keying
requestStopWork on it could report a turn canceled while the child was
still working.
Track the child's exit event instead, and resolve only on that. A
disposed transport whose child has not exited still rejects, so the
caller keeps refusing a cancellation it cannot prove.
Also move the in-flight tests' command detection into named
`exitOnCommand` helpers, and reuse the extracted `onOmpCommand` for
`replyToCommands` so the OMP file matches the Pi one.
* Let a stop survive an stdin failure that precedes the child's exit
A dying child breaks its stdin pipe a fraction before it emits `exit`.
The broken pipe fails the in-flight abort and starts a termination, and
deciding at that moment saw `exited` still false, so the stop was
refused for a runtime that was already on its way out. That is the
reported wedge again, reached through a narrower race.
close() now records the termination it starts, and requestStopWork waits
for it before deciding. A child that goes on to exit resolves the stop; a
child that outlives SIGKILL still rejects it.
---------
Co-authored-by: paseo-bot[bot] <paseo-bot[bot]@users.noreply.github.com>
* Read an archived agent's history without its working directory
Loading an archived agent for history rejected when its working
directory no longer existed, so `paseo logs <archived-agent>` and
`get_agent_activity` failed with "Working directory does not exist"
once the workspace archive removed the worktree the agent ran in.
Resuming a persisted session already distinguishes an interactive
resume from a history load, but residency was settled after the
session config was prepared, so the directory check ran for both.
Settle residency first and require the directory only for a session
that will actually run in it.
* Assert the archived transcript is readable, not just loadable
* Let archived timeline reads succeed without resurrecting the workspace
The auto-archive test asserted that history reads for an archived agent
in a removed worktree are rejected. That was the old behavior, not the
invariant its comment describes. Assert the invariant instead: the
reads succeed, the removed worktree stays removed, no workspace
observation comes back, and the next agent lifecycle is unaffected.
* Archive the agent only after its turn is finalized
The regression test dispatched a run and archived immediately. Dispatch
resolves before the provider appends the reply to its history, so the
transcript the test reads back could be written after the archive, and
the read could find nothing.
Wait for the turn to be finalized first. The provider appends the
assistant message before it emits turn_completed, so once the turn is
finalized the transcript is already on disk.
---------
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
* fix(server): keep workspaces whose project root is unreachable
Reconciliation archived every active workspace whose cwd returned ENOENT on
a single stat, including the pass that runs at daemon startup. An external
disk that has not mounted yet, or a network share that is briefly down, made
every workspace on it disappear from the sidebar along with its sessions, and
nothing brought them back when the directory returned.
A missing path is only evidence that a workspace was removed when the project
it belongs to is still readable. When the project root is missing too, the
whole location is unavailable, and projects already persist through that.
Workspaces now follow the same rule.
Closes#4982
* fix(server): read project roots after workspace directories
Reading the project roots first left a window where a volume could go away
between the two reads, marking a project reachable while its workspace looked
missing. Reading them after inverts the skew: a volume that disappears mid-pass
makes the project unreachable, which withholds an archive instead of causing
one.
---------
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
* Resolve a fork checkout's pull request by its head repository
A fork checkout's pull request lives in the parent repository, and its
head repository is the only thing that tells it apart from every other
fork's pull request on the same branch name. The per-checkout lookup
asked `gh pr list --repo <parent> --head <forkOwner>:<branch>`, which
`gh` cannot answer: it forwards the whole value as a head branch name,
so the query always came back empty. Every synchronous refresh then
overwrote the status the background poll had resolved, and the workspace
showed no pull request.
Fork checkouts now resolve through the REST pulls endpoint, the one
lookup that takes a head repository, and read each resulting pull
request through the ordinary view path. That also removes the
disambiguation the bare branch name would have needed: `--head main`
already returns thirty pull requests upstream, far past the ten-item
candidate page, so a fork checkout on a common branch name would still
have lost its own pull request.
The three existing fork tests asserted the unanswerable invocation while
their fake runner answered it regardless of the head value. They now run
against a runner that matches head refs the way `gh` does.
* Let a failed fork lookup surface, and keep the checked-out attempt
Two corrections to the fork pull request lookup.
An operational failure on the REST lookup, a rate limit, a timeout, a
DNS failure, was caught and returned as an empty list, so a refresh
reported "no pull request" for a checkout that has one. The lookup now
only handles what it can act on and lets the rest reach the caller, the
same way the non-fork list path does.
A fork branch reused for several attempts can carry the checkout's head
commit on an older pull request than the three the lookup reads. The
page is now requested in full and the attempts carrying the checked-out
commit are read first, so the bound on full reads no longer decides
which attempt the workspace can see.
* Use a function declaration for the head-commit predicate
* Use a function declaration for the test pull request helper
---------
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
* fix(app): reattach workspace labels when a connection is restored
connectionChanged() drops the workspace label replica on every connection
change but never puts it back: the only caller of connectWorkspaceLabels()
is refreshAll(), which is one of the two branches of requestDemandRefresh().
Both branches mark the connection epoch as satisfied, so when route-only
demand refreshes first, the later full-directory demand early-returns and the
label channel stays detached for the whole connection.
The picker then renders rows merged from other online hosts, every row
disabled and no explanation, since the offline case has no MenuHint.
Attach the replica where the subscriptions are restored, so it no longer
depends on which demand branch wins the epoch.
* fix(app): keep the label replica attached across refreshes
Reattaching on every online connection meant refreshAll() could reconnect a
replica that was already attached, and connect() begins by disconnecting,
which publishes an offline status. The picker disables every row while a host
is offline, so a plain refresh made the menu flicker disabled.
Reuse the live connection when the same client is already attached and just
refresh the catalog. Cover both the flicker and the reconnect path, which is
the case the fix is named after.
* test(app): verify workspace labels after route refresh
The route-first regression must expose the restored label catalog used by sidebar chips, and the test client must accept the directory query passed by the public observer.
* fix(app): show label catalog load errors in picker
An attachment failure reaches the label host snapshot, but the picker only showed mutation errors. Surface the host error beside the disabled or empty label list so the failure is visible in the affected workspace.
* test(app): keep label attachment coverage in directory suite
The directory fake already owns the connection and demand setup. Reuse it for the route-first, full-demand, refresh, and reconnect cases so this regression follows the existing suite and cleanup.
* fix(app): localize label catalog load errors in picker
The host snapshot retains diagnostic details, while the workspace picker gives users the translated catalog-load message.
---------
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
* feat(website): rework sponsorship with homepage sponsor spots
The founder note now states how the project is funded (sponsorship and
Paseo Hub) instead of describing the product, and the same note and
backing links render on the homepage and /sponsor from one module so the
two cannot drift.
Companies can sponsor one of four homepage logo spots at $500 a month
through Stripe rather than GitHub Sponsors, because they need invoices
and a vendor relationship. The spots render as placeholders until sold;
approving a sponsor is an entry in src/data/sponsors.ts. The checkout
URL is a placeholder until the Stripe Payment Link exists.
* feat(website): point sponsor spot checkout at the Stripe payment link
* docs(readme): add a sponsors section
* docs(readme): add the sponsors section to the translated READMEs
* docs(website): document the sponsor approval and Stripe cap workflow
* fix(app): keep sidebar order keys that end in whitespace
A project view key embeds the project's path, so a directory whose name
ends in a space produces a key that ends in a space. The sidebar order
store trimmed every key on write, so the stored key could never match
the key the sidebar looks up. computeSidebarOrderUpdates saw the project
as missing from the order on every pass, appended it, and the store
trimmed it away again, setting a fresh array each time. The effect that
applies those updates re-ran on every render until React gave up with
error #185, and the app failed to load until the folder was renamed.
Keep keys verbatim on the runtime setters, dropping only blank keys and
duplicates. Migration of persisted state still trims, where a stray
space is an artifact of an older format rather than part of the key.
Fixes#4880
* test(app): give AsyncStorage a real in-memory stub instead of mocking it
`docs/testing.md` bans `vi.mock`, and the test added here mocked
`@react-native-async-storage/async-storage` only because the module has no
Node implementation: its default export reaches for the native module, so
every write from the persist middleware rejects and a test that touches a
persisted store drowns in unhandled rejections.
That is exactly the case `test-stubs/` exists for, reached through the
resolve.alias in `vitest.config.ts` the same way react-native-unistyles,
react-native-svg and expo-linking are. The stub is a real in-memory
key-value store with the same contract, so a test can read back what a
store wrote rather than inspect a recording double.
`sidebar-view-store.test.ts` carried the same mock for the same reason and
no longer needs it either.
* test(app): give the AsyncStorage stub the multi-key calls the app uses
`provider-snapshot-cache` declares its own `ProviderSnapshotStorage` port and
is handed the real AsyncStorage, so it calls `multiGet` and `multiRemove`
during reconciliation and cleanup. The stub stopped at the single-key calls,
which is fine until a test reaches the provider cache and then fails with
`storage.multiGet is not a function`.
`multiSet` comes along with them so the three multi-key calls behave as a set
rather than leaving the next caller to notice the gap the same way.
* test(app): cover a project folder name ending in a space end to end
Adds a browser regression for #4880: the daemon accepts a directory whose
name ends in a space, and the sidebar has to render its project row instead
of tearing the app down with "Maximum update depth exceeded" on every load.
* test(app): move the trailing-space spec setup behind a seeded-project helper
docs/testing.md asks for E2E bodies that read like plain English with the
setup in helpers, and for helpers that own the project record until cleanup.
The spec did its directory creation, daemon seeding, and removal inline.
seedProjectNamedWithTrailingSpace owns the directory and the daemon project
and returns a cleanup, the same shape seedWorkspace returns, which cannot be
used here because mkdtemp appends the random suffix and so cannot produce a
name that ends in a space. The body is now goto, assert the row, assert no
error screen.
The row is still located by its sidebar-project-row test id, which is how
every other sidebar spec and the shared sidebar helpers address a project row.
---------
Co-authored-by: L4XB <lukas.buck@e-mail.de>
Co-authored-by: paseo-bot[bot] <paseo-bot[bot]@users.noreply.github.com>
The daemon dumps a heap snapshot next to the server package when it nears
its heap limit. Those files are multi-gigabyte debugging artifacts we want
to keep on disk, not stage.
feat(claude): add Opus 5.5 with its model capabilities
Combine the catalog and runtime identity work from #5191 with the
per-model effort default from #5197. Claude Code 2.1.280 introduces
Opus 5.5 with 1M context, medium effort, and always-on thinking.
Resolve prefixed minor model releases before their major version so
Opus 5.5 retains its identity and capabilities after a turn.
Co-authored-by: Leonardo G. Urci <leonardoguimaraesurci@gmail.com>
Co-authored-by: Sebastian Galindo <seb@jexhq.com>
Co-authored-by: Rodrigo <rodrigo.parisusao25201@gmail.com>
When the client reconnects with a live sync cursor, the daemon answers
with only the agents that changed, and the replica rebuilds the merged
directory from what it already holds. That rebuild went through
projectAgentDirectoryEntry, which returned null for any agent without a
projectPlacement, so those members never reached the merged map,
commitSnapshot treated them as absent, and removeFromDirectory deleted
them from the store.
An agent hydrated from its timeline or restored from the replica cache
has no placement: neither normalizeAgentSnapshot nor deserializeAgent
sets one. Every other path in the same flow derives a placement from the
working directory instead of dropping the agent, so the projection does
the same now via resolveProjectPlacement, and the function is total.
The conversations were always intact on the daemon; only the client lost
them, which is why a full restart brought them back until the next
reconnect drained the sidebar again.
Co-authored-by: Bag Raul Cristian <17720135+bagutzu@users.noreply.github.com>
* fix(codex): page through Codex threads when listing importable sessions
Codex answers thread/list with at most 100 rows per response whatever
limit is requested, and returns a cursor for the rest. The adapter read
the first response and returned, so Import session could only ever offer
the newest 100 Codex threads: on a store of 456 threads, a host-wide
search that asks for a 500-thread scan saw 100.
Follow nextCursor until the caller's window is full, and ask Codex to
page by last use so the scanned window matches how the import sheet ranks
candidates. Unknown keys are ignored by Codex, so older builds keep their
creation-time order.
Closes#5073
Co-authored-by: 3ae3ae <83870598+3ae3ae@users.noreply.github.com>
* fix(codex): end the thread scan on a page that carries no threads
The previous guard only caught a cursor that repeated immediately, so a
Codex cursor cycle across two or more pages (A to B to A) kept paging
for ever behind a caller that had already timed out.
Every page that continues the scan now has to carry rows, which bounds
the loop by the requested window whatever cursors Codex hands back.
* fix(codex): keep a thread once when the scan sees it on two pages
Paging by last use means a thread touched while the scan is running
sorts to the front and comes back on a later page, so the same
conversation could be offered twice and displace another one inside the
window.
Identify each thread as it arrives and keep it once. A page that brings
nothing new now ends the scan, which also covers a cursor that cycles
while still serving rows.
---------
Co-authored-by: paseo-bot <paseo-bot@users.noreply.github.com>
Co-authored-by: 3ae3ae <83870598+3ae3ae@users.noreply.github.com>
Collapses the 0.9.0-beta.1 and 0.9.0-beta.2 entries into one stable entry
covering v0.8.0..83f9fba20. Adds #4413; the other post-beta.2 fixes correct
regressions introduced inside this same range.
* fix(app): keep the draft-create handoff until the new chat is current
Creating an agent from the draft composer on web flashed the "Updating
messages" panel toast for 40-200ms. That toast means "this chat may be out
of date, we are catching up" — a chat this client just created cannot be.
The timeline reducer released the create handoff at the first synchronized
page, but the visibility catch-up flag only clears once catch-up settles,
and a fresh chat always has a parked follow-up "after" page because live
rows arrive during the tail fetch. Between the two releases the state
machine saw a hydrated chat with no handoff and a pending catch-up, which
resolves to the resume toast. #4863 turned that branch from silent to
status, which is what made the window visible.
Both signals now flip in one synchronous call: createViewedTimelineSync
gains an onTimelineCurrent port, called from setVisibilityCatchUpReady —
the single place the sync declares a chat current, already covering the
parked follow-up and the fetchLatestTail overflow path. The owner
implements it by clearing the handoff. The optimistic "running" override
therefore lasts one extra round trip.
The state machine and sync-status rules are unchanged; they were correct.
* fix(app): release the create handoff when a chat leaves timeline demand
Closing a new chat's tab before catch-up settled left its sent attempt in
useCreateFlowStore forever. Reopening it in the same session then supplied
optimistic-create continuity for an authoritative chat: the panel showed it
as running and suppressed "Updating messages" while the real catch-up ran —
the state this fix exists to protect. An offline or slow host widens that
window from milliseconds to minutes.
Leaving the demanded set ends the owner's obligation to a chat exactly as
reaching current does, so onTimelineCurrent becomes onCatchUpEnded and fires
on both. It reports after the membership commit, so the sync never describes
itself mid-transition. Disconnect and backgrounding preserve demand and
therefore preserve the handoff.
* fix(app): count chat Find matches across the whole chat
Chat Find searched the whole chat but its counter read "1 of N in
message", where N was the occurrences inside the selected message. The
per-message split is an implementation detail of how the client verifies
and highlights a hit; a reader counting matches is counting the scope
they searched. The counter now reads "3 of 7" for the position across
every message that holds a hit, and Next/Previous walk that sequence,
crossing message boundaries and wrapping at the ends. Per-message
occurrence stays inside the model as navigation state.
The total needs a per-message count for messages that are not on screen,
so the daemon now reports one. It counts with the same matcher the
browser uses on rendered text — query words joined by any whitespace,
case-insensitive, never across a Markdown block boundary — which also
stops it from reporting locations the client would reject. The client
replaces the estimate with the rendered count as each message is
revealed, so the total is exact for everything that has been on screen.
`count` is optional on the wire and tagged COMPAT(timelineSearchCount):
a host that omits it is worth one match per located message until a
reveal corrects it.
* fix(server): stop chat search reporting matches the browser cannot show
Falling back to the whole Markdown source when the per-block scan found
nothing reported a match whenever the query words straddled a block
boundary — "one target" across "target one\n\ntarget two" — and on
syntax the reader never sees. The client always rejected those
locations, so before the whole-chat counter the cost was a wasted round
trip. Now that the total sums the host's per-message counts, a rejected
location inflates the number the reader sees until navigation reaches
that message and drops it. The block scan alone is the whole rule.
The model's Location derives from the wire type again, with count
narrowed to required: the search boundary fills in the COMPAT default,
so the interior never re-checks it.
* fix(app): find text in assistant messages that streamed in the open chat
Chat find failed with the generic "check the host connection" text
whenever the oldest hit sat in a response that streamed while the chat
was open and had more than one Markdown block. Presentation rendered
such a message as one row per block with ids `<messageId>:block:n`,
while hydrated history stayed one row with the raw message id, and every
message-addressed DOM lookup (find reveal, scroll-to-message, history
reveal, the render-cap expansion) only knew the raw id. The reveal step
polled for a row that never existed and gave up after five seconds.
The defect was the dual rendering, so this removes it: every assistant
message is a block group however it arrived, single-paragraph messages
included, so a row id is never a message id. Rows carry
`data-message-id`, `getStreamItemMessageId` is the one way from a row to
its message, and find, scroll-to-message, history reveal and selection
copy address messages through it. Chat find force-mounts every row of
the selected message under virtualization so counts span the whole
message, and pins the chosen occurrence to its row while the scroll
settles. Link reference definitions fold into their neighbouring block
so history never paints an empty row. Find failures now say what went
wrong (connection, history changed, could not show the match) instead
of blaming the connection for everything.
* fix(app): let the Markdown parser decide which blocks are link definitions
A hand-rolled CommonMark regex rejected destinations markdown-it accepts,
such as escaped spaces, so those definitions still became their own empty
row and the neighbouring paragraph lost its link. The block splitter now
parses the block with the same parser: no tokens plus registered
references means definitions only. The two new chat find journeys move
their mechanics into named helpers so the test bodies read as intent.
Control+F moves the caret forward on macOS, so chat Find and file pane Find
were stealing a keystroke the text editor owns. Chat Find listens on
`document` in the capture phase, so the composer received an already-cancelled
Control+F and the caret never moved.
Each Find surface carried its own copy of the shortcut predicate and only the
terminal's was platform-aware. There is now one predicate, `isFindShortcut` in
`@/pane-find`, that takes the platform as a parameter: Command without Control
on macOS, Control without Command elsewhere, no Shift or Alt. Chat Find, file
pane Find, the Find widget's own query box, terminal Find, and the terminal
emulator runtime all call it, and `terminal-find-shortcut.ts` is gone.
App surfaces source the platform from `findShortcutPlatform()`, which wraps the
app's existing `getShortcutOs()`. The predicate stays free of React Native
imports because the terminal emulator runtime is bundled on its own into the
terminal WebView; its default platform and `getShortcutOs()`'s browser branch
now share one user-agent read in `@/utils/mac-user-agent`.
Closes#5097
Derive Pi thinking options from each model's thinkingLevelMap, read Pi's effective level back after create, resume, set_model and set_thinking_level, surface failed state reads instead of persisting the requested level, and show the thinking control for single-level models.
Closes#4382
Co-authored-by: therainisme <therainisme@qq.com>
Co-authored-by: trim21 <i@trim21.me>
* fix(app): subscribe only the chats this session opened
Open-chat membership was sourced from the persisted workspace layout, which is
restored from disk at launch and carries a tab for every chat the user has ever
opened on the host. Every member got an authoritative timeline catch-up at
session start, and each of those fetches resumed its agent on the daemon: one
provider session per historical tab, every launch.
Two things followed from each resume. Registration stamped `updatedAt` over the
restored timestamp, and the resumed agent started with no attention because the
resume path never read the stored flag. Both were persisted, so restarting Paseo
rewrote every workspace's last-used time in the sidebar and cleared Ready to
review on chats the user never looked at. The timestamp loss was permanent:
workspace `statusEnteredAt` is re-derived from `updatedAt` on every daemon start.
Keep the open set on the session instead. A chat joins it the first time the user
opens it and stays until its tab closes or the session ends, so hiding, switching
workspaces, evicting a retained view, backgrounding and reconnect all preserve the
subscription, with no recent-agent limit. Layout keeps its release role and loses
its source role.
Harden the producer as well, so a resume from any caller stays inert: registration
adopts restored timestamps rather than stamping now, and resume carries the stored
attention through.
* fix(agents): keep a resumed agent's updatedAt from moving backwards
Dropping the registration stamp exposed a skew the stamp had been erasing.
`extractTimestamps` read `lastActivityAt ?? updatedAt`, but the two diverge:
renaming, labelling, restoring from archive, marking unread, and clearing
attention all move `updatedAt` on an unloaded agent without touching
`lastActivityAt`. Eight of 854 agent records on one dev machine carry that skew,
up to eight days of it.
A resume then handed clients a timestamp older than one they already held, and
`acceptAgentDirectoryUpdate` drops any state update that goes backwards. Marking
a chat unread without opening it, then opening it, left the unread dot stuck in
History, the agent list, Command Center and the tab: the daemon cleared attention
and the client refused the update.
Read both fields and take the newest, which is already the rule
`resolveStoredAgentPayloadUpdatedAt` applies to the list payload. Move that
function beside the other stored-record readers as `resolveStoredAgentUpdatedAt`
so one rule covers both paths.
Also replace the `!options?.updatedAt` inference in `registerSession` with an
explicit `restoring` flag. The inference was correct for all four call sites, but
it made a second meaning out of a field, and the next caller to seed a timestamp
would have opted out of activity stamping silently.
* test(app): assert a reloaded layout subscribes only the chat it restores into
No e2e reloaded the page with a populated workspace layout and asserted which
agents the daemon acknowledged, so the regression this branch fixes had no
end-to-end guard. Opening four chats across two workspaces leaves a tab per chat
in persisted layout; after a reload only the restored chat may be subscribed,
because subscribing resumes that agent on the daemon.
Verified it fails when session-context's layout callback is pointed back at
replaceVisibleAgentIds: the three restored tabs nobody opened join the
acknowledged set.
uploads.github.com returned HTTP 500 five times during the v0.9.0-beta.1
release, each costing a ~50 minute rebuild. The upload step passed every
artifact to one gh invocation, so a single failed asset discarded the rest
and failed the job.
Upload each asset separately with bounded retries, shared by the macOS,
Linux and Windows jobs.
Metro's jest workers OOM transforming the generated ws-outbound.aot.js
validator, the last module in the web bundle. Two consecutive v0.9.0-beta.1
macOS x64 builds died at 5081/5082 modules on that file.
Set max-old-space-size per runner: 8192 on the 14 GB Intel runner, 4096 on
the 7 GB arm64 runner.
* fix(app): restore character-paced streaming reveal
Restore the v0.8 character reveal model and remove the word-level fade implementation so streamed assistant and reasoning text remains opaque, grapheme-safe, and immediately completes when a turn ends.
* fix(android): preserve inverted timeline selection
React Native's prebuilt Android library does not consume source patches under node_modules. Register the compiled scroll manager before the core package so inverted child-reveal calculations leave focus and text selection in place while normal and explicit scrolling remain unchanged.
Desktop and CLI helpers imported daemon bootstrap through the server root, while supervisor configuration retained the full wire schema tree. Expose existing capabilities through narrow package entrypoints and share configuration schemas without loading message definitions. Guard the dependency trees without tree-shaking so unused exports cannot hide the runtime cost.
* fix(app): open chat find from the focused composer
Listen during capture so the composer text input cannot swallow the find shortcut before it reaches chat search.
* test(app): verify draft retention on first chat find
* feat(plugins): unify source installation and simplify plugin settings
* feat(plugins): install npm sources and review CLI updates
Treat install selectors as one-time choices. Keep source identity separate from the installed revision, and resolve ordinary updates from the current upstream default. npm only acquires directories; plugin loading remains independent of npm.
* fix(plugins): preserve CLI diagnostics and exercise reviewed updates
Move new source capability coverage to real browser journeys. Update the requirements regression to exercise preview and apply, and preserve the existing duplicate-selector diagnostic across positional option scopes.
* test(plugins): remove unused pending helper
* test(plugins): pack registry fixtures with the Windows command runner
* test(plugins): assert failed client contribution setup
* docs(plugins): consolidate guides and document npm publishing
* docs(plugins): introduce publishing choices before npm instructions
* test(plugins): own browser journey cleanup in a fixture
* feat(plugins): prepare scaffolded packages for publishing
* fix(website): render phone mockups correctly in Safari
Replace trigonometric CSS scaling with measured numeric ratios after Safari placed phone content outside its clipped frames. Scale desktop mockup corners with the frame so they remain proportional on mobile.
* test(website): expose the website browser test suite
* test(website): run mockup regressions in CI
* fix(app): dock the New workspace composer like a chat on phones
Only the composer is bounded by the viewport; the setup fields sit above it in a
bottom-anchored stack that moves up as the draft grows. The keyboard reservation
is retained on every screen, so the composer keeps one height across keyboard
transitions. Removes the per-screen reserve policy.
* fix(app): share composer docking across mobile hosts
Extract chat's retained capacity and platform keyboard motion so chat,
workspace drafts, and New workspace share one sizing and dismissal boundary.
Keep the native drivers and settled portal reconciliation unchanged.
* fix(app): preserve overlays in centered composer docks
Honor the optional overlay child in both layouts so changing dock placement does not silently discard part of its content.
* test(app): resolve keyboard controller through its web entry
The shared keyboard entry point makes stream tests load the provider. Keep its real ESM dependencies inside the Vitest resolver so CommonJS does not bypass the React Native web alias and parse native Flow sources.
* fix(app): keep native chat scrolling responsive
Place keyboard dismissal behind scrollable content so an idle Android Fabric JS responder cannot intercept native drags. Keep decorative form padding transparent to iOS hit testing and exercise idle native scrolling after submission from every composer host.
* fix(plugins): preserve nested provider subagent links
The plugin adapter routed descendants to their managed root but discarded their direct parent when publishing descriptors. Preserve runtime-to-public child identities and forward optional spawning tool IDs.
* test(plugins): separate opening and live subagent scenarios
* test(server): retry terminal fixture directory cleanup
Match the daemon harness cleanup policy so transient Windows EBUSY locks do not fail terminal ownership tests after their assertions complete.
* feat(plugin): support cross-host navigation
Keep route construction inside Paseo while letting plugin surfaces target a host other than the currently selected one. Calls without a server ID retain selected-host behavior.
* test(plugin): cover cross-host navigation
Exercise workspace and agent navigation against a second live daemon so the SDK contract is verified through the caller-visible route instead of mocked helper forwarding.
* test(plugin): clarify cross-host navigation journey
Keep the scenario at the caller-visible intent level while shared helpers own route waits and visibility mechanics.
---------
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Show a spinner attachment while a selected file is read and uploaded, yield between 128 KiB chunks so the UI stays responsive, validate every file in a batch before sending any of them, and leave one SelectedFile shape across the picker, drag-and-drop, and the upload path.
* fix(history): preserve chronology and clarify search matches
Keep workspace names primary and highlight each searched field so results explain why they matched. Filter before cursor pagination and retain date buckets.
Constrain shared subsequence matching to each whitespace-delimited word while preserving fuzzy abbreviations in Command Center and other consumers.
* test(history): reuse response helper in pagination regression
* test(history): express browser journey through named helpers
* fix(app): start menu entering animation only once the surface is placed
Reanimated's web entering path snapshots the element rect at animationend and
writes top/left back when it cleans up the keyframe 750ms later. When the
150ms animation ends before anchor and content measurement resolve, that
snapshot is the off-screen measuring position and the open menu jumps back to
-9999. Remount the surface when its position resolves so the animation starts
from the final position.
* fix(app): stabilize native composer sizing after edits
* test(app): verify workspace composer space after keyboard dismissal
* test(app): start the hold-to-delete regression mid-draft
From the end of the draft the pre-fix input also returns to baseline. Placing the cursor
eight lines into the draft before the first hold-delete leaves the old build at the
capped height with the placeholder visible, and the fixed build at one line.
* fix(server): preserve comparison base when restoring worktrees
Restore archived worktrees from the workspace's saved comparison base instead of using the feature branch as its own base. Preserve the branch HEAD and allow restore when an exact comparison ref no longer exists.
* test(app): isolate draft preference checks from menu animation
* fix(voice): speed up replies and hide prompt wrappers
Keep voice prompt cleanup in the client presentation path so provider history and live messages render consistently without rewriting canonical input. Correct native TTS scheduling and threading, and reconnect interruption to speech detection and session cancellation.
* test(voice): clarify playback and timeline regression journeys
* test(voice): centralize native worker cleanup and repair fixture path
* test(app): exercise workspace eviction without reloading the app
* test(app): preserve streamed output during workspace eviction
Hold the turn until the workspace is evicted instead of dropping all stream events during navigation. Slow CI runners can finish the entire response before navigation ends; waiting for producer completion before release makes that ordering deterministic.
* test(app): stabilize subscriptions during workspace eviction
Opening another chat replaces the timeline subscription, making buffered frames from earlier generations unroutable. Preopen all chats before holding the stream, finish production before eviction, and assert both early and final response content with no subscription replacement.
* fix(server): keep plugin sockets out of the application socket lease
A plugin's IPC socket is claimed by the application socket lease as soon as
its client sends its first ping. If that client's heartbeat is then starved
for longer than APPLICATION_SOCKET_LEASE_MS — a blocked event loop, a
SIGSTOP, a sleeping machine — the daemon reaps the socket with "Closing
physical WebSocket with expired application lease".
The plugin never comes back. plugin-process.ts builds its DaemonClient with
`reconnect: { enabled: false }`, so unlike an app or menubar client it cannot
redial, and there is no "Client disconnected; waiting for reconnect" because
no reconnect is ever scheduled. Every later `paseo.*` host API call then
fails with "Transport not connected (status: disconnected)" while
`paseo plugin ls` still reports the plugin as running. Only
`paseo plugin reload <id>` recovers it.
The lease is a backstop for an abandoned application socket. A plugin socket
is IPC to a child this daemon spawned and already supervises through the
child's own close, so heartbeat silence there is not evidence of abandonment
— it only means the child is busy or paused. Reaping it strands a plugin that
was about to resume, which is strictly worse than leaving it attached.
Ordinary sockets are unaffected and still reaped on lease expiry; the second
test pins that down so the exemption cannot widen unnoticed.
Fixes#4883
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(server): let a plugin session recover when the daemon closes its socket
The lease exemption stops the common cause, but any daemon-side close still
stranded a plugin for good: the child's DaemonClient was built with
`reconnect: { enabled: false }`, and even with it enabled the child had
nothing to redial onto, because the runtime attached exactly one
PluginSessionSocket per load and routed frames to that captured const.
Enable reconnect on the plugin client, and have the runtime stand up a
replacement session when a socket closes underneath a live child. Frames now
follow the current socket, and `loaded.sessionSocket` / `sessionClosed` track
the replacement so stop and teardown still await the right attachment. The
session host already supported this — attaching a second socket for the same
plugin id gives it a second, independently owned session.
A plugin on its way out must not be resurrected. Both stop paths
(`stopPluginById`, `stopAll`) clear the catalog entry before they close
anything, so the catalog check is what distinguishes a shutdown from a socket
worth replacing; a dead child is caught by `child.connected`.
The client waits DEFAULT_RECONNECT_BASE_DELAY_MS (1500ms) before redialling
and gives up after DEFAULT_CONNECT_TIMEOUT_MS, so the replacement is attached
long before the first attempt arrives, and a lost race would retry anyway.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* refactor(server): trim the comments on the plugin session fix
The reasoning lives in the commit messages and the PR. Leave only what the
code cannot say for itself: why a plugin socket is not an application socket,
and why the catalog check is what distinguishes a shutdown.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(server): re-attach a plugin session on the child's redial, not on close
Attaching eagerly when the socket closed put a socket in place that nobody
was speaking to yet. Two ways that goes wrong, both reachable under exactly
the conditions #4883 describes - a child whose event loop is starved:
- Nothing sends a hello on the replacement, so the host closes it after
HELLO_TIMEOUT_MS (websocket-server.ts:1310), and that close attached
another. A wedged child drives that cycle until it exits, filling the
plugin's 500-entry log ring with re-attach lines.
- Frames the child had already queued arrive after the close and land on the
replacement while it is still pending, which closes it with
WS_CLOSE_INVALID_HELLO (websocket-server.ts:2151) and attaches another.
Route frames through the current socket instead, and stand a replacement up
only when a hello arrives while the old one is closed - the one point that
can tell leftovers of the dead session from a genuine redial, since the IPC
channel carries no session id. A starved child now attaches nothing until it
wakes up and redials.
Also re-check the guards after the attach await and close the replacement if
they no longer hold, and surface a failed re-attach through appendLog and
notify rather than only a daemon log line, so a plugin left without a session
is visible instead of silently repeating the original bug.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* refactor(server): follow the server style guide and drop test timing
Two review findings against packages/server/CLAUDE.md.
Helper shape (CLAUDE.md: `function` declarations over arrow assignments,
object parameters when a function takes more than one argument). The
re-attach helpers were arrow assignments closing over `loadPlugin` locals, and
two took `(frame, isBinary)` positionally. They are now private methods taking
a single object. The mutable session state they shared - the current socket,
the in-flight flag, the loaded plugin - moves into a PluginSessionBinding the
methods read, which is what made the closures hard to lift out before.
Test determinism (CLAUDE.md: no timing/randomness). The reconnect tests waited
on fixed 500ms sleeps and 15s vi.waitFor polling windows, so they inferred
lifecycle state from elapsed time. The tracked session host now exposes
waitForHellos, resolving on the handshake itself, and the lazy-attach case
runs against a stub child that is driven frame by frame: close, then a stale
frame that must not attach, then a hello that must. No sleeps and no polling
remain in these tests, and the file's suite drops from ~40s to ~11s.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(plugins): verify host API and observation recovery
* test(plugins): await connection and catalog events
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
* fix(app): preserve composer capacity when the keyboard closes
Releasing the keyboard reservation enlarged an unchanged capped draft. Retain the last editing reservation while still recalculating capacity for viewport changes and subsequent keyboard openings.
* perf(app): isolate composer editing and resize updates
Draft and cursor subscriptions rerendered the chat and full composer on every native edit. Give editing consumers a live text source, preserve attachment identity on text updates, and read viewport settling state without a render.
Read live input text for web resize measurement so removing per-keystroke props does not collapse a draft when its width changes.
* fix(app): restore current draft when the composer remounts
The last replacement command does not include subsequent edits. Seed remounted editors from the live draft snapshot so switching launch modes restores the saved text without subscribing the composer to every edit.
* fix(app): synchronize composer resolution state before paint
Avoid an extra passive-effect delay when forwarding forge resolution state from the isolated binding. Exercise live-text width measurement through the actual composer E2E journey instead of a hook-only test component.
* fix(app): keep reconnect toast transitions continuous
Preserve the toast surface when its content changes, and apply initial appearance before mounting screens so settings hydration cannot restart a visible toast.
* test(app): use supported appearance QA coverage
runPluginBuild called node:child_process spawn with shell: false, so bare
commands like `npm` failed on Windows with `spawn npm ENOENT` because npm
is a .cmd script. Route through the shared spawnProcess helper, which
already selects shell: true for bare commands and .cmd/.bat scripts on
Windows and quotes arguments for cmd.exe.
Side effects inherited from spawnProcess, on every platform:
- Child env is sanitized via createExternalCommandProcessEnv, stripping
ELECTRON_RUN_AS_NODE, ESBUILD_BINARY_PATH and PASEO_* control keys that
previously leaked from the daemon into plugin builds.
- windowsHide: true, so builds no longer flash a console window on Windows.
Adds behavioral tests for preparation.ts. Windows cases use it.runIf and
run only on Windows hosts; CI is ubuntu-only.
Co-authored-by: ABorakati <ABorakati@users.noreply.github.com>
* fix(client): preserve creation on older hosts
Creation keys must not disable workspace and agent creation when the host lacks request receipts. Keep capability selection inside the client and degrade to the original RPCs with the initial prompt intact.
* test(creation): own legacy daemon cleanup in fixtures
* fix(client): preserve original prompted creation on legacy hosts
Legacy receipt support does not preserve create-and-prompt semantics when split into a later send. Reuse the original creation RPC so naming, creation context and startup receive the same input as before.
* fix(plugin): continue one timeline item across ACP chunks without messageId
The plugin ACP reducer minted a new fallback id for every
agent_message_chunk / agent_thought_chunk that carried no messageId, so
a streamed reply rendered as one block per chunk, splitting words and
Markdown delimiters (#4699). Keep one fallback id per chunk kind and end
it on a chunk of the other kind, a tool call, a plan, a user message, or
a turn boundary, matching the server-side ACP provider.
* fix(plugin): end the other chunk kind's item on any chunk
A chunk of one kind now ends the other kind's fallback item before the
content or id is looked at, so an explicit-id text chunk between two
id-less thoughts yields three items instead of merging the thoughts.
The test declares the streamed updates through the connector harness
instead of handling protocol frames itself.
* test(plugin): verify ACP message boundaries and rendered Markdown
* test(app): synchronize rewind setup and menu selection
---------
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
* feat(app): add pane Find to file source
* fix(app): float the pane Find widget and tighten its chrome
Find pushed the first lines of the file down, ran 480px wide with 32px
ghost buttons, and let the replacement input start left of the query
input. Float the CodeMirror top panel out of flow so the widget overlays
the top right of the pane, move the disclosure toggle into a gutter that
spans both rows so the two fields share a leading rail, put the match
count inside the query field, and drop the icon controls to the shared
pane-toolbar chrome the file bar above already uses.
* fix(app): keep the active match visible and drop the Find button
The floating widget covered the active match whenever the match sat under
it and the document was too short to scroll clear. CodeMirror's own panel
scroll margin cannot reveal a first line, so the file integration now owns
placement: the widget renders as an overlay beside the editor, the model
measures it against the active match, and flips it to the opposite corner
only when the current corner collides. Hysteresis keeps navigation from
bouncing the widget between corners.
Also removes the standalone Find button shown while Find is closed, per
user direction. Cmd/Ctrl+F is the entry point.
* fix(app): stop the Find placeholder from hiding Go to line
Hiding `.cm-panels` suppressed every CodeMirror panel in the file editor,
including the Go to line dialog that Mod-Alt-g opens through the same
search keymap. Hide only the inert Find placeholder panel instead, by its
own class, and leave the shared panel container alone.
* feat(app): find text throughout chat history
Discover candidate transcript locations on the daemon, then reuse prompt navigation to load history and verify rendered occurrences on the client. Keep search state inside its own modules without changing timeline or task presentation contracts.
* fix(app): show actionable chat search failures
Keep transport error details in the search model while showing localized retry guidance. Use named client request options for timeline search.
* feat(app): add pane Find to file source
* fix(app): float the pane Find widget and tighten its chrome
Find pushed the first lines of the file down, ran 480px wide with 32px
ghost buttons, and let the replacement input start left of the query
input. Float the CodeMirror top panel out of flow so the widget overlays
the top right of the pane, move the disclosure toggle into a gutter that
spans both rows so the two fields share a leading rail, put the match
count inside the query field, and drop the icon controls to the shared
pane-toolbar chrome the file bar above already uses.
* fix(app): keep the active match visible and drop the Find button
The floating widget covered the active match whenever the match sat under
it and the document was too short to scroll clear. CodeMirror's own panel
scroll margin cannot reveal a first line, so the file integration now owns
placement: the widget renders as an overlay beside the editor, the model
measures it against the active match, and flips it to the opposite corner
only when the current corner collides. Hysteresis keeps navigation from
bouncing the widget between corners.
Also removes the standalone Find button shown while Find is closed, per
user direction. Cmd/Ctrl+F is the entry point.
* fix(app): stop the Find placeholder from hiding Go to line
Hiding `.cm-panels` suppressed every CodeMirror panel in the file editor,
including the Go to line dialog that Mod-Alt-g opens through the same
search keymap. Hide only the inert Find placeholder panel instead, by its
own class, and leave the shared panel container alone.
* feat(app): find text in terminal scrollback
* test(app): account for terminal write completion before barriers
* fix(terminal): only Cmd+F opens Find on macOS; Ctrl+F reaches the shell
* test(app): exercise terminal find shortcuts against real ptys
* test(app): wait for visible vim lines before terminal page down
* test(app): account for vim page overlap in terminal shortcut journey
* feat(app): add pane Find to file source
* fix(app): float the pane Find widget and tighten its chrome
Find pushed the first lines of the file down, ran 480px wide with 32px
ghost buttons, and let the replacement input start left of the query
input. Float the CodeMirror top panel out of flow so the widget overlays
the top right of the pane, move the disclosure toggle into a gutter that
spans both rows so the two fields share a leading rail, put the match
count inside the query field, and drop the icon controls to the shared
pane-toolbar chrome the file bar above already uses.
* fix(app): keep the active match visible and drop the Find button
The floating widget covered the active match whenever the match sat under
it and the document was too short to scroll clear. CodeMirror's own panel
scroll margin cannot reveal a first line, so the file integration now owns
placement: the widget renders as an overlay beside the editor, the model
measures it against the active match, and flips it to the opposite corner
only when the current corner collides. Hysteresis keeps navigation from
bouncing the widget between corners.
Also removes the standalone Find button shown while Find is closed, per
user direction. Cmd/Ctrl+F is the entry point.
* fix(app): stop the Find placeholder from hiding Go to line
Hiding `.cm-panels` suppressed every CodeMirror panel in the file editor,
including the Go to line dialog that Mod-Alt-g opens through the same
search keymap. Hide only the inert Find placeholder panel instead, by its
own class, and leave the shared panel container alone.
* chore(ci): audit packaging costs and repeated test execution
Read-only audit: Linux packaging and smokes consume 12-14 minutes; all three CLI shards repeat the 294-test source suite and build the server twice. Preserve the 17 required contexts, move packaging to main, run CLI source tests once, and consolidate repeated E2E setup into journeys. Historical logs and the complete audit were reported outside the repository.
* ci: move packaging to main and remove repeated CLI suites
PR runners were rebuilding distribution artifacts and executing the entire CLI source suite on all three E2E shards. Keep packaging coverage after merge, let the local E2E runner own its dependency build, and execute source tests once in an existing required check. Root typecheck already validates plugin examples.
* test(ci): consolidate journeys and remove repeated startup work
Run user journeys with shared setup, retain one Hub CLI contract, and weight the existing Playwright shards using observed costs. Remove implementation-only and ad-hoc measurement checks. Await root-watch closure and publish creation failure commands atomically so teardown and failure injection have completion boundaries.
* test(hub): remove incidental observation and speculative shutdown changes
Hub control RPCs need a connected client without an agent observation. Preserve the failed-create subscription contract and remove product shutdown changes whose Windows cleanup cause was not reproduced.
* fix(client): clear the scheduled retry when a connect attempt opens a transport
An explicit connect() during reconnect backoff opened a new transport but
left the scheduled retry armed. When it fired on the healthy connection it
disposed the transport and reconnected, dropping requests in flight. This
is why owned-subscriptions-old-daemon could still fail after #4858 added
the explicit reconnect. attemptConnect now owns the rule ensureConnected
already applied; the new unit test fails on the old client.
* test(e2e): keep the completed-block, font-readiness, and live-page guards inside journeys
- agent-stream-ui: the auto-scroll journey anchors on the completed intro
block by content and requires its Markdown root to stay the same mounted
node while later blocks stream (#1989 guard, reshaped off the tail block).
- changes-pane: the code-typography journey holds font loads and requires
no diff geometry before they resolve, then applies the font change.
- agent-timeline-pagination: the live-output page load runs against a
thirty-minute mock stream and gates the release on the running turn, so
the status assertion no longer races a ten-second turn.
* test(desktop): install the Electron binary once before the unit suite
Electron 44 downloads its binary on the first Node require("electron")
instead of at npm ci. Vitest workers importing electron-log/main raced
that download and extraction (File exists on dist/electron, then
"Electron failed to install correctly" in a sibling worker) on both
Ubuntu and Windows desktop-tests jobs. Run the package's idempotent
install-electron in one process before vitest forks.
* ci(desktop-packages): pin actions and smoke packaging-path pull requests
Pin checkout, setup-node, and upload-artifact to full commit SHAs. Keep
packaging off ordinary PR CI, but run the Linux package smoke on pull
requests that touch packages/desktop, nix, or the workflow so a packaging
or sandbox regression cannot land on main unchecked. The desktop release
workflow already builds and smokes its own artifacts on all platforms.
The mock provider splits "**Bold" into two frames, "**Bo" and "ld". The
helper forwarded both back-to-back and asserted the strong span reads
"Bold", which only holds when the browser commits both frames to the
store in one reducer flush and the message first mounts with "**Bold".
When a frame boundary lands between the two tasks the message mounts
with "**Bo" and word pacing holds "ld" until the whitespace after it
arrives, so the span reads "Bo" until the gate releases. That is the
designed behaviour of the reveal, and the batching is up to the
browser's task scheduling.
Stop after "**Bold text" instead: the space after "Bold" ends the word,
so every batching reveals "Bold" and never more than "text". Forcing the
frames into separate commits reproduced the CI failure before the change
and passes after it.
* fix(app): keep open chats current and show resume progress
Keep open-chat delivery independent of mounted views, freeze hidden chat rendering, and catch up visible chats before hidden ones. Distinguish host reconnection from timeline freshness so cached messages do not silently appear current after resume.
* fix(app): prevent sync status from overlapping action errors
Share the existing panel toast slot so action errors remain readable during reconnect and catch-up. Verify the interaction and draft retention in the real browser, preserve the tested freeze transition, and refresh the Nix dependency hash for react-freeze.
* test(hub): expose complete creation and message retry failures
Partial response assertions hid the creation error and allowed duplicate responses with incomplete agent data. Check complete success envelopes, the shared agent snapshot, and null message errors so future failures print the actual error payload.
* fix(app): dismiss retained chat menus before freezing
An async fork navigates before its closing state commits, so freezing the source chat could leave its portal backdrop blocking the destination. Remove the shared menu surface in the inactive commit before suspension.
Keep regression assertions aligned with open-chat subscriptions, per-agent incremental recovery, plugin contribution lifetimes, and rendered image completion.
* test(app): expect the reloaded plugin to contribute its pill again
A full reload discards the pressed pill with the page, then evaluates the plugin again and its agents snapshot re-adds the pill. The old count-zero assertion only passed on main because the chat was not ready yet when the URL settled; open chats now subscribe from session start, so the pill is rendered by then.
* fix(app): suspend hidden chats on web only
react-freeze is Suspense with a never-resolving thenable. Suspending a committed subtree hides its host nodes, tears down layout effects, and detaches refs, which changes Fabric ownership of the retained chat. Split the boundary by platform: the web file keeps the two-commit freeze, the native file keeps the chat mounted and live, and the retained-panel signal remains the only thing that stops background work. Restore the mobile-panels rule the branch had rewritten.
* fix(creation): never read a receipt while replacing it
Admission reads the creation receipt while the runner replaces it atomically. On Windows a rename over a file this process still has open for reading fails with EPERM, and that error marked an otherwise completed creation as failed (#4865). Queue reads and writes per receipt so one owner never overlaps them, and serialize the record when the write is queued because the runner mutates it afterwards. No retry on EPERM: the conflict is removed rather than tolerated.
Linux cannot reproduce the rename failure, so the Windows server-tests job (creation/index.test.ts and hub/execution-session.websocket.test.ts) is the verification for this change.
* feat(app): reveal streaming text as one continuous fade front
Streaming assistant text is released by word and every character fades in
over the same 150 ms envelope from a start time owned by the message. Start
times never decrease in reading order, so a line paints as a single front
moving left to right, however the text is chunked. Per-word envelopes with an
internal stagger let the next word's first letter overtake the previous word's
last letter, which read as holes and flashing words at the real frame rate;
100 ms frame strips hid it.
Native renderers receive the fade ranges as a prop on a host view wrapping the
root Text, so ranges and text land in the same Fabric mount transaction. The
earlier binding sent ranges through a separate async module call keyed by view
tag and had to reconcile them against the view's text; on a fast phone the UI
thread lags JS by several commits, mounts and calls interleave, and every
reconciliation heuristic guessed wrong. Native code no longer infers newness
or creates start times.
The mock load-test provider now separates cycles with a blank line so a cycle
heading is not appended to the previous paragraph.
* fix(ci): refresh streaming dependencies and isolate E2E setup
* test(app): distinguish fade cleanup from Markdown replacement
* test(app): detect restarted fades during Markdown updates
* docs(app): keep streaming guidance focused on constraints and QA
* test(app): control animation time in Android fade assertions
Fixed wall-clock sleeps let emulator scheduling select the sampled animation frame. Inject the animator time source and drive paint at exact timestamps within one UI-thread operation; production keeps the system clock. Verify the mid-fade sample and the exact cleanup boundary without sleeps, polling, or retries.
* fix(test): compile the desktop renderer before lifecycle checks
The lifecycle fixture treated Metro /status as app readiness. Its first
Settings click therefore spent its 90-second UI budget compiling the cold
bundle. An isolated cold-cache run restricted to one CPU reproduced the
click timeout before the app runtime had loaded.
Reuse the browser suite's existing bundle warmup before launching Electron,
and preserve Metro output in the lifecycle artifacts. The UI timeout,
build-request timeout, lifecycle assertions and product behavior are unchanged.
CI did not preserve Metro output, so its precise startup timing is not
recoverable from the failed run. The retained log makes subsequent startup
failures diagnosable without relying only on an empty renderer text dump.
Validation: three complete lifecycle E2E runs under private Xvfb displays;
existing Metro readiness tests; app typecheck; targeted lint; root format.
* test(file-observer): await burst delivery through native recovery
The deletion phase required every event within 10 seconds, but native
coalescing recovery uses a 30-second safety audit followed by the existing
reconciliation debounce. An idle diagnostic excludes that delayed audit
and therefore cannot act as a completion barrier.
Await the required delivered events instead, preserving every deletion
assertion and the existing overall test watchdog. A real-filesystem test
with controlled native notifications reproduces 95 outstanding paths at
the old deadline and verifies completion when the safety audit delivers
them. No production observer behavior changes.
* test(file-observer): verify burst recovery through public events
The controlled-clock regression constructed a private native backend,
bypassing the interface used by consumers. Consolidate recovery coverage
into the public observer integration test instead: drive real writes,
deletes and directory renames, then await every required public event,
including the 450 surviving files at their renamed paths.
No public capability was added: subscribe callbacks already express
completion. No production behavior or test timeout changes.
* test(app): drive Android fade tests through the native host
The instrumentation suite constructed TailFadeInAnimator directly, advanced
its private clock, and counted FadeInSpan instances. It now mounts the
WordFadeHost that React mounts, feeds it the ranges prop the bridge sends,
and asserts rendered pixels: hidden before a start time, a monotone front
inside and across words, settled frames equal to the baseline, and no frame
callbacks left after settling. Span counts and internal timing state are no
longer asserted.
Choreographer cannot be advanced deterministically from a test and the fade
epoch is the message's wall-clock start time, so the host takes a
WordFadeFrameClock (time plus frame callbacks) as its smallest public seam;
production keeps the Choreographer-backed default.
Fixture text is sized in pixels so every sample stays on one line at any
density; at 420 dpi the 32sp fixture wrapped and the column helper summed
glyphs from the first line.
* fix(app): prevent and recover workspace layout crash loops
* fix(desktop): capture resized browser tabs without hanging
A resident guest can paint fresh pixels after resize while capturePage's
native surface-copy request remains pending. The same failed guest returns
a fresh frame through frame subscription, so waiting for resize or another
animation frame does not correct the failing copy request.
Capture one subscribed frame within the existing five-second budget and
release the subscription on completion or cancellation. Preserve temporary
frame production and its original throttling policy. Remove viewport copy
retries and E2E screenshot polling; assert the resized image dimensions.
Refs #4866. Three full real-Electron browser journeys passed under Xvfb,
including physical clicks and fresh screenshots while the window is hidden.
* test(app): choose Agent before exercising the runtime catalog
The newly merged catalog journey assumed an empty workspace opened an agent draft tab. This branch opens the New launcher, so create the agent draft tab explicitly before checking its composer and model choices.
* fix: settle closed browser captures and validate persisted layout fixtures
Destroying a guest during frame subscription left its screenshot pending until the capture deadline and blocked queued captures. Reject at destruction, remove the listener on every exit, and return browser_tab_closed. The regression proves immediate settlement and queue release.
Reuse the unchanged persisted layout schema from a UI-independent module so the recovery fixture validates storage before editing it.
* test(app): encapsulate Explorer layout setup and cleanup
Keep persisted layout schemas and corrupted-state construction inside the seedCorruptedWorkspaceLayout helper. Use the existing withWorkspace fixture for cleanup in all four Explorer journeys so test bodies follow a straight setup, action and assertion flow.
* test(app): target the selected workspace in layout recovery
The corruption helper selected the first persisted entry, so a multi-workspace journey could leave the intended workspace untouched. Resolve its exact persistence key from the requested workspace ID and fail setup when absent. The failing-first journey now preserves an earlier workspace with an agent draft tab while recovering the selected workspace.
* feat(app): give compact Changes an overview and a straight rail
Three fixes to the Explorer's Changes tab on phones, where there is no
tree rail and the diff is the only view of what changed.
No placeholder stat while the sidebar slides in. `useCheckoutDiffQuery`
reported `isLoading` only when the query was enabled and connected, so an
inactive retained panel settled on `files: []`. The comparison toolbar
summed that into "+0 -0" and the body rendered "No changes" for a diff
nobody had fetched yet, both replaced a moment later by real numbers. The
result now derives from the payload alone: no payload means loading,
whatever the query is doing. Callers already handle loading correctly, so
nothing downstream needed a special case.
Jump to file. A floating action button over the diff opens a sheet with
the same changed-files tree the desktop rail renders; tapping a file
closes the sheet and scrolls the diff there. `ChangedFilesTree` moved out
of `diff-pane.tsx` into its own module so both surfaces own one tree, and
its list is now the sheet-aware one so it scrolls with the sheet gesture
on native. `DiffDocument` takes a `contentInsetBottom` so the button
never traps the last line.
Rails on the compact header. The tab labels sat at 20dp and the close X
at 16dp while every toolbar row below them used 12dp leading and 8dp
trailing. The close button was also a hand-rolled Pressable rather than a
ToolbarButton. `paneContentToolbarTrailingPadding` now takes whether the
row's last control is a bare glyph or a framed one: a glyph's hitbox
overhangs its ink so the row pads by less than the rail, but the compact
git-actions trigger paints its own frame and was landing flush against
the screen edge. The commits header chevron moved to the depth-0 tree
rail. Desktop geometry is unchanged.
* fix(app): preserve Explorer touch targets and reset file overview
* fix(app): preserve diff selections across repaints
Header typography refreshes and height resizes changed the repaint callback
and inadvertently cleared active source selections. Keep selection updates
stable while retaining explicit source and selection-layout invalidation.
* test(app): express diff copy regression as user actions
* test(app): express file overview flows as user actions
Send every original tool call and each whole accumulated assistant message through plugin transformers before display grouping or Markdown splitting. Preserve streaming callbacks, original payloads, native Markdown block identities, and chat outline reading position. Reject legacy fragment caches and retain failing-first plugin-interface and browser regressions.
Fixes#4673. Fixes#4789. Consolidates #4820.
Co-authored-by: Matt Cowger <matt@cowger.us>
Co-authored-by: Jegor Kitskerkin <jegor.kitskerkin@gmail.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
* fix(providers): keep duplicate catalogs from breaking agent drafts
Normalize model identities before publication and when materializing older
catalogs, preserving the first definition and configured overrides. Keep
explicit default variants separate from the base model selection.
* fix(providers): preserve saved variant identities across resumes
Persist the selection encoding so legacy names remain literal. Refresh provider persistence when a selection changes, before another prompt is sent.
* test(app): let fixtures own catalog journey cleanup
* fix(providers): preserve the existing default variant contract
Keep one Default catalog choice and let the provider resolve an omitted variant. Remove the encoding and persistence machinery; other variant IDs retain their literal meaning.
* test(creation): expose failed completion after conflicting requests
Creation returns a failed snapshot instead of rejecting. Assert the result before its side effects so platform failures report the underlying error.
* test(app): wait for the seed client after daemon restart
The browser and seed client reconnect independently. Wait for the seed connection before issuing post-restart RPCs instead of assuming browser readiness covers both clients.
* test(app): isolate visible prompts and checkout diff fixtures
Inactive tabs retain message DOM, so prompt assertions must inspect visible rows. Ignore the local fixture remote before observation rather than deleting it after the daemon has started tracking the checkout.
* fix(file-observer): preserve final deletions when batching events
A duplicate creation notification followed by deletion was collapsed into an update, hiding final absence from consumers that distinguish deletes. Keep final deletes while retaining update semantics for atomic replacement.
* fix(desktop): wait for Metro HTTP readiness before launching Electron
Expo briefly listens while probing port availability. A successful TCP connection can therefore launch Electron before Metro starts and leave it exiting on connection refusal. Require the configured renderer origin to report the running packager status.
* test(ci): consolidate fixture and readiness corrections
Await the connection and UI states each journey owns, isolate Git fixture contents before observation, and hold real Markdown frames at asserted checkpoints. Keep creation completion diagnostics explicit without claiming the original Windows failure is fixed.
* fix(desktop): wait for Metro HTTP readiness before launching Electron
Expo briefly listens while probing port availability. A successful TCP connection can therefore launch Electron before Metro starts and leave it exiting on connection refusal. Require the configured renderer origin to report the running packager status.
* test(ci): consolidate fixture and readiness corrections
Await the connection and UI states each journey owns, isolate Git fixture contents before observation, and hold real Markdown frames at asserted checkpoints. Keep creation completion diagnostics explicit without claiming the original Windows failure is fixed.
* test(desktop): describe Metro readiness through HTTP states
Keep protocol mechanics in the fixture and assert readiness only after a successful packager response. Cover non-success status and unrelated HTTP content without inline synchronization in the test body.
Restore provider/model selection in Android tablet modal windows by giving both native modal hosts their own gesture-handler root. Preserve phone sheet and web portal behavior.
Include native regression scripts, test dependency resolution, and verified CI test corrections. Generated media and logs are excluded. Refs #4475 and #4377; consolidates #4721 and #4771.
Co-authored-by: Mike P. Kuhl <mkuhl@softmachine.at>
Co-authored-by: Cameron Chin <cjcrjc@users.noreply.github.com>
* fix(agents): prevent OOM from cumulative tool history
Repeated progress updates retained complete historical copies of growing tool output. Retain projected items on ingestion and serve them for every history fetch, preserving source sequence coverage for catch-up. Keep intermediate payloads only in streaming; confine compatibility degradation to child transcripts and overwritten fork checkpoints.
* test(client): expect projected subagent capability in hello
* fix(app): report unexpected child history failures
Keep connection failures recoverable while forwarding unexpected fetch and reconciliation errors to diagnostics. Name observer arguments to keep server and agent identities explicit.
* test(agents): assert projected history after rewind
* docs(protocol): tag projected child compatibility gates
* fix(app): keep growing composers below the header
Native keyboard translation left the dock at full height, so intrinsic composer growth could extend behind the header. Reserve the keyboard destination at motion boundaries and bound the composer to its stationary viewport while preserving native input scrolling and animation.
* docs: reuse existing test suites and automation
Keep plan proposals at their original timeline position through approval, rejection, and cancellation. Make plan cards collapsible while retaining readable history after follow-up prompts and daemon restart.
* fix(subscriptions): preserve older-host workflows and explicit timeline recovery
Keep established app and SDK workflows available on older daemons without emulating independent directory filters. Restore live timeline membership without choosing the consumer's history recovery policy. Preserve daemon filesystem paths separately from legacy workspace IDs.
* fix(client): keep legacy workspace replies scoped to the requested page
* fix(server): probe Cursor thinking options per model
Cursor ACP only reports thought_level for the current model, so the catalog was copying Haiku's Off/On onto Grok 4.6 until the next probe happened to land on Grok.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(server): drop inherited thinking options when an ACP catalog probe fails
A rejected model switch was leaving the probe session's thinking list on the failed model, so Grok could still advertise Haiku's Off/On.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(server): probe per-model thinking when session/new has no thought_level
Composer/Auto as the Cursor ACP default omits thought_level, so the catalog helper skipped every model switch and Grok kept an empty thinking list.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(server): discover model options without changing preferences
Use the provider model catalog extension instead of probing model switches that persist user preferences. Keep provider-specific discovery in each shim and validate extension responses at the boundary.
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
* fix(app): keep incomplete Markdown formatted while streaming
Treat unfinished formatting at the growing assistant-message tail as provisional so delimiters and link destinations do not flash while tokens arrive. Completed messages retain ordinary Markdown parsing.
* test(app): express streaming Markdown checks as user actions
* fix(agents): restore archived Codex workspaces without losing focus
History selection must survive without cached agent records, and reading an archived Codex thread must not acquire an interactive writer. Keep workspace restore explicit, serialize native lifecycle transitions, and leave transcript lifetime with its owner.
* test(app): express Codex restore specs as user journeys
* test(app): finish restore fixture cleanup after teardown errors
* test(app): align restore coverage and await fixture readiness
Workspace restore leaves agents archived until explicit Unarchive. Keep that distinction covered for multiple agents. Compile the recovery fixture before its interaction test starts, and wait for sidebar agent hydration before asserting tab presence.
* fix(app): let touch workspace titles use the available width
Omit trailing stats when the permanent touch menu replaces them. Keep concealed stats in layout only for temporary desktop shortcut hints, preserving hover and shortcut geometry.
* test(app): express sidebar width checks as user journeys
* fix(desktop): preserve Linux sandboxing wherever the host allows it
Chromium checks its Linux sandbox before main.js, while package installation probes run as root and miss AppArmor restrictions on desktop users. Choose the launch flags before exec and always install the SUID alternative for native packages. Test published artifacts as an ordinary user with namespaces both denied and allowed; never repair permissions in the smoke harness.
* fix(desktop): retain the Linux release build baseline
* fix(ci): allow canceled desktop smoke uploads to stop
* test(desktop): verify the installed RPM sandbox under AppArmor
* fix(ci): resolve packaged desktop dependencies before installation
* chore(desktop): keep QA evidence out of the repository
* fix(daemon): keep lifecycle operations on the selected instance
Restart reconstructed launch inputs and address fallback could redirect a home-selected shutdown to another daemon. Let supervisors publish bound endpoints, retain their worker restart contract, and share captured local lifecycle ownership between CLI and Desktop.
* test(daemon): verify bounded desktop readiness and cancellable artifacts
* test(cli): verify reload target hints and durable offline pairing
* test(daemon): align lifecycle fixtures with ownership and targeting
* fix(daemon): preserve reachability when status details time out
Provider discovery can outlast a status request after authentication succeeds. Keep local connection facts separate from unavailable details, while explicit remote queries retain their error outcome.
Migrate CLI and packaged Desktop fixtures to persistent managed configuration and explicit targets. Isolate generic Git observation from forge discovery and reuse disposable CLI homes on Windows.
* test(daemon): observe restart through the worker status RPC
The bounded CLI status observation may omit worker details while an authenticated connection remains healthy. Exercise the app-style restart through its full RPC and retain the replacement observation, with a real slow provider probe covering the distinction.
* fix(app): reload to the project picker after a render error
Keep the recovery boundary above the router so Reload starts a new navigation tree at the picker instead of remounting the failed workspace. Preserve saved layouts and normal startup restoration.
* test(app): exercise reload through fixture routes
Give each observation a server-issued ID owned by its physical connection. Preserve snapshot-only reads and combined list-and-subscribe, source-bound replies, lifecycle cleanup and legacy wire compatibility.
* perf(app): optimize wrapped measurements and checkpoint native layout experiment
Compute full grapheme geometry only for final wrap fragments. Preserve shaped-text search behavior while bounding additive wrap probes. Keep the Android background layout experiment isolated from the normal diff renderer; native renderer integration and iOS bindings remain unfinished.
* perf(server): reduce cold diff generation work
Keep active diff reads owned across invalidation and wait for the initial watcher inventory before cold generation. Batch patch and blob reads while preserving per-file size isolation, bounded output, and daemon responsiveness. Avoid constructing fallback tokens when full-file highlighting already covers both sides.
* test(server): exercise oversized highlighted diff rejection
The single-line fixture now bypasses highlighting under the long-line guard, so it no longer reaches the structured payload limit. Use short dense lines to keep testing oversized rejection and continued socket usability.
* fix(server): queue fresh diffs for forced reads
A forced request following a repository mutation must not reuse an older active build. Invalidate that result and coalesce waiting callers onto one subsequent build. Give the cache a named object input and completed-value type.
* test(server): share deferred diff loader setup
* test(server): name completed diff cache setup
* refactor(server): use aliases for new diff imports
* fix(server): keep shipped diff imports resolvable by Node
The TypeScript build preserves path aliases without a runtime resolver. Restore relative imports for shipped code and declarations, and correct the server import guideline that prompted the regression.
* fix(desktop): throttle hidden browsers between screenshots
Keep Chromium background throttling enabled outside serialized captures. Temporarily enable frame production and wait for fresh paint so hidden-window screenshots remain usable without continuously animating idle guests.
* fix(desktop): share screenshot deadline across paint and capture
* test(desktop): wait for browser fixture readiness before sampling
* test(desktop): exit Electron after screenshot verification
CI completed every screenshot assertion but remained alive after app.quit. Exit the standalone test explicitly and keep its deadline active until process shutdown.
* test(desktop): clean browser profile after Electron exits
Explicit Electron exit skips quit handlers, and removing the profile before shutdown lets Chromium recreate it. The launcher owns the temporary profile and removes it after the child exits.
* test(desktop): reuse browser E2E for hidden screenshot regression
Exercise the running app and browser MCP tools within the existing browser journey. Remove the separate Electron fixtures, npm script, and CI step.
* fix(desktop): restore Linux launcher identity
Electron now derives Linux window identity from the scoped package name before app startup. Pin the existing Paseo.desktop identity in packaged metadata and check the built archive in the packaged-app smoke test.
* test(desktop): declare packaged smoke archive dependency
* fix(desktop): update Nix hash for smoke dependency
The prefix allowlist hid Fast for GPT-6 Astra and exposed it for models outside documented Codex Fast support. Use explicit model IDs and avoid claiming a universal usage multiplier.
Suppress repeated Claude compaction heartbeat markers at the provider and re-arm marker creation at compaction and turn boundaries. Verify emitted events through the public session interface, including subsequent compactions after interrupted or abandoned turns.
Closes#4390.
Co-authored-by: Tom Gringauz <17593920+tomgrin10@users.noreply.github.com>
* fix(codex): show asynchronous questions and preserve their lifecycle
* fix(server): reject concurrent replies to the same question
* test(codex): use native temporary paths in question fixtures
Preserve the selected workspace when delayed creation finishes, while still starting the requested agent or terminal. Keep normal navigation when the initiating screen remains active, and preserve newer draft text and context.
Verified with before/after recordings, ten passing real-browser journeys using isolated daemons, focused regression tests, green CI, and independent review.
QA: https://github.com/getpaseo/paseo/tree/qa/workspace-focus-2986
Co-authored-by: Christoph Leiter <christoph@leiter.io>
Make Find discoverable at the top right of editable and read-only web file panes, using shared controls backed by CodeMirror search. Preserve literal case-insensitive buffer search, keyboard focus and navigation, optional replacement, Undo, and save.
Add a paseo.cafe link to the Plugins card on the landing page and a
section at the top of the related projects doc. The card link is accented
so it reads without hover; the card now takes a list of links instead of
one hardcoded link.
* perf(app): reduce avoidable work when opening large diffs
Large snapshots were recursively reconstructed for reference stability, while native scroll worklets captured complete file models. Preserve identity at the data producers and prepare text for the visible window without rebuilding unchanged files.
* test(app): await commit diff query completion without polling
* chore(app): remove profiling artifacts from the source tree
What's new opens an adaptive sheet — bottom sheet on compact, centred card on
desktop — that fetches CHANGELOG.md from the repository and renders it with the
app's own chrome. The desktop update callout, the sidebar help menu, and a new
row in Settings → About all open it; the sheet header links out to
paseo.sh/changelog.
The parser commits to two facts only: `##` starts a release and `###` starts a
section. Section titles are read from the document rather than matched against a
list, and everything under a section passes through verbatim to the Markdown
renderer, so a future callout, block quote, code fence or image needs no parser
change. Fences are tracked because a fenced sample is the one thing that can
fool a line scan.
The document is fetched rather than bundled because the app has to describe a
release that shipped after it was built, which is the update callout's whole
purpose. It goes straight to the repository rather than through a host: the
notes describe the app, and routing them through a daemon would make them
depend on which host happens to be connected. Nothing is fetched or parsed
until the sheet opens.
The latest release renders as soon as there is one and the rest of the first
page follows in a transition, so React slices that work around the opening
animation instead of committing hundreds of list items at once.
Restored tabs render before the terminal list arrives. Mark their titles as loading until metadata is available so they do not briefly display the generic terminal name.
* fix(app): preserve plugin provider icons in model buttons
Pass the selected host to the composer glyph so it resolves the same plugin SVG as the model picker. Require the nullable glyph host prop to catch omitted identity at compile time.
* test(app): isolate plugin icon regression setup
Restore unread attention on finished workspaces and preserve the reminder when leaving the current workspace. Cover desktop and compact read flows, and retain change-only file paths for deletion observation.
Co-authored-by: Edi Hasaj <34984294+edihasaj@users.noreply.github.com>
* fix(cli): initialize current Hub triggers
Hub init still authored and deployed project bundles after hosted configuration moved to organization triggers. Generate the self-contained trigger contract and keep project bundle deployment behind the existing --project path.
* fix(cli): harden Hub trigger setup
* docs(hub): align public setup guides with organization triggers
Move supported protocol capabilities into client defaults and make session traffic subscription-driven. Preserve timeline readiness and reconnect recovery across SDK, CLI, and plugin consumers.
ACP SDK extension dispatch can finish after a later response, letting vendor config notifications escape commit or rollback depending on pipe chunking. Consume synchronous vendor transforms at the stream boundary before forwarding later responses, and reproduce coalesced single and burst notifications through a real subprocess.
The ESM main-module guard compared import.meta.url with "file://" + process.argv[1],
which never matches on Windows (file://D:\a\... vs file:///D:/a/...). On the Windows
desktop release job, scripts/github-release.mjs therefore printed nothing and
gh release upload received an empty release lookup ("release not found"), leaving
v0.8.0-beta.1 without a Windows build. Compare resolved paths instead, shared by
every release script that runs as a CLI.
* fix(app): keep terminal keyboard focus stable
Clearing the terminal buffer remounted the shared native input, triggering an IME hide/show cycle. Keep text replacement in place and make the composer explicitly reset its editor to preserve multiline layout clearing.
* test(app): verify terminal keyboard continuity on Android
Replace the new JSDOM regression with a real Android harness. It detects focused native input replacement and IME hide/show requests through terminal toolbar controls, and fails with the previous remount behavior.
Accept plugin requirements against the runtime version or its stable core while preserving the legacy version boundary. Validate all example manifests, correct the ACP example metadata, and prepare plugin author documentation for the beta.
* fix(plugins): keep React out of the server plugin host
* fix(plugins): align SDK guidance and tests with runtime boundaries
* fix(plugin): loosen the react peer to ~19.1.0
The plugin SDK pins its react peer to exactly 19.1.0, and that pin is
what a downstream `npm i -g @getpaseo/cli` resolves against. So every
user ends up with react 19.1.0 in their global node_modules, where
corporate dependency scanners flag it: the CVE-2025-55182 advisory range
covers 19.1.0 even though the vulnerability itself lives in
react-server-dom-webpack/-parcel/-turbopack, none of which the CLI
installs. The user is then left hand-patching node_modules or
uninstalling Paseo.
Widening the peer to ~19.1.0 lets a downstream install pick the latest
19.1.x patch (19.1.9 today) while this repo stays on 19.1.0, which the
root override still pins.
Keeping the repo itself on 19.1.0 is deliberate. react-native 0.81.5
embeds a 19.1.0 Paper renderer that hard-throws "Incompatible React
versions" on any exact mismatch, and it is reachable from Fabric builds
too: RendererImplementation.js requires the Paper shim unconditionally
in findNodeHandle, unstable_batchedUpdates, sendAccessibilityEvent and
three other entry points, and react-native-gesture-handler re-exports
findNodeHandle. Bumping the app's react is therefore not safe until
react-native moves.
The published CLI tree contains no react-native, so downstream installs
are not subject to that constraint.
Supersedes the earlier 19.1.5/19.1.9 version-bump approach on this
branch, which would have broken the mobile app.
* Enforce example import ownership including client types
* Update Nix dependency hash for plugin peer metadata
* Group plugin APIs under client and server entry trees
* Resolve plugin type dependencies through declarations
* Validate declarations for implicit plugin type imports
* Preserve optional imports and validate declaration references
* Refresh Nix hash for plugin declaration resolver
* Canonicalize plugin paths consistently on Windows
* Normalize plugin ownership paths across resolver boundaries
---------
Co-authored-by: liujin0506 <liujin0506@qq.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
* fix(app): keep reconnect enabled while backgrounded
Visibility should flush cached state and accelerate foreground recovery without disabling normal reconnect backoff. Preserve healthy sockets and retry whenever the OS permits execution.
* test(app): clarify the background reconnect journey
* test(app): name reconnect screenshot helper inputs
* test(app): wait for stable content before delayed-history scroll
* chore(app): keep reconnect QA evidence in the PR body
* fix(app): show workspace plugin panels in Explorer menu
Let the host-filtered launch catalog identify toggleable views and their targets. Explorer uses the same identity comparison for every view while plugin panels remain workspace-scoped.
* test(app): express Explorer panel regression as user steps
* fix(plugin): loosen the react peer to ~19.1.0
The plugin SDK pins its react peer to exactly 19.1.0, and that pin is
what a downstream `npm i -g @getpaseo/cli` resolves against. So every
user ends up with react 19.1.0 in their global node_modules, where
corporate dependency scanners flag it: the CVE-2025-55182 advisory range
covers 19.1.0 even though the vulnerability itself lives in
react-server-dom-webpack/-parcel/-turbopack, none of which the CLI
installs. The user is then left hand-patching node_modules or
uninstalling Paseo.
Widening the peer to ~19.1.0 lets a downstream install pick the latest
19.1.x patch (19.1.9 today) while this repo stays on 19.1.0, which the
root override still pins.
Keeping the repo itself on 19.1.0 is deliberate. react-native 0.81.5
embeds a 19.1.0 Paper renderer that hard-throws "Incompatible React
versions" on any exact mismatch, and it is reachable from Fabric builds
too: RendererImplementation.js requires the Paper shim unconditionally
in findNodeHandle, unstable_batchedUpdates, sendAccessibilityEvent and
three other entry points, and react-native-gesture-handler re-exports
findNodeHandle. Bumping the app's react is therefore not safe until
react-native moves.
The published CLI tree contains no react-native, so downstream installs
are not subject to that constraint.
Supersedes the earlier 19.1.5/19.1.9 version-bump approach on this
branch, which would have broken the mobile app.
* Update Nix dependency hash for plugin peer metadata
---------
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
* fix(protocol): keep 0.7.2 clients working with 0.8 hosts
Gate plugin timeline rows and blocked workspace setup statuses per client capability so app-store version lag does not make existing agents unloadable. Preserve canonical pagination and document the intended Hub provider-mode authority.
* test(protocol): clarify compatibility test preconditions
* fix(server): support OpenCode 2 in terminal activity plugin
OpenCode 2 rejects V1 hook-object plugins, so the installed
paseo-terminal-activity.js failed to load on every OpenCode 2
session. Ship one definition object that serves both generations:
OpenCode 2 loads id + setup() and ignores the 1.x server entrypoint,
OpenCode 1 loads the same object through server().
* fix(server): preserve terminal activity across plugin generations
* test(server): drain persistence before reload test cleanup
---------
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
* Revert "Restore cached conversations before reconnecting (#4421)"
This reverts commit 1c51b52d07.
* fix(app): restore provider loading on native
* fix(app): preserve native navigators across appearance changes
Reapply the isolated appearance boundaries and native menu transform origin from #4421 so appearance hydration preserves native navigator and gesture host lifetimes.
* fix(app): retain cached baselines during live updates
Read the timeline concurrently with agent preparation and reconcile display-only cache rows beneath the live head using the existing reducer. Merge cached directory entries beneath live state while preserving the existing checkpoint race guard.
Supplying a list controls scroll-container ownership, not whether content can move the sheet. Preserve the shared expand-scroll-dismiss interaction for model selectors and plugin lists, with native gesture regressions and updated plugin guidance.
* feat(plugins): enforce declared Paseo version requirements
Treat manifests without a Paseo requirement as pre-0.8 plugins so the first breaking plugin release offers an explicit migration path. Check the daemon and app independently before plugin execution, and preserve installed Git revisions when updates are incompatible.
* fix(nix): refresh dependency hash for plugin requirements
* refactor(plugins): validate test manifest inputs and name compatibility input
* fix(app): restore cached conversations before reconnecting
Give each host one timeline write authority so cache restores, live events and fetched pages publish consistent rows and coverage. Persist a moving 40-entry restart page independently of loaded scrollback, and keep native navigation mounted while startup settings hydrate.
* fix(app): recover cache writes after initialization failures
Keep accepted writes pending and retry initialization without another UI update. Publish the storage index only after loading succeeds, while queued host identity changes still settle in order.
* test(app): wait for sidebar and workspace transitions
Viewport intersection and leaving the creation route can precede completed UI transitions. Wait for the layering hit test and selected agent tab before asserting or navigating again.
* fix(app): restore provider loading on native clients
The native AbortSignal lacks throwIfAborted, so successful provider responses failed before reaching every model selector. Preserve both cancellation boundaries using the supported aborted property and the existing query cancellation error.
* Settle autonomous Pi turns triggered by extensions
Treat Pi's agent lifecycle as authoritative for extension-triggered turns that do not have a Paseo provider turn ID. Keep locally handled extension commands immediate, but defer autonomous completion until agent_settled.
* Fail autonomous Pi turns on process exit
* Fix cancellation of autonomous Pi turns
* Synchronize Pi cancellation regressions with queue clearing
* Release Pi interruption state at its terminal boundary
---------
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
* fix(providers): remember model selections across workspace creation
Keep saved model intent independent of catalogue readiness and use the caller-owned host and directory from the first render. Share discovery through provider-owned keys while preserving execution context.
Give each target one committed snapshot, publish actual transitions atomically, and share catalogue content across client directory associations. Regression coverage exercises successful repeated creation and provider lifecycle boundaries.
* fix(providers): preserve shared cache bodies during eviction
Admit directory references with their bodies, retain reference recency during eviction and recovery, and remove the unused persisted index. Refresh known workspace catalogues concurrently.
Make repeated-creation picker states explicit and seed mode tests from the installed provider catalogue, preserving assertions across Codex versions.
* fix(providers): bound discovery independently per provider
Settings refresh repairs all remembered targets, which otherwise starts an unbounded number of discoveries. Admit work after catalogue-key deduplication and keep each provider’s budget independent so a stalled provider cannot block another. Preserve active accounting across configuration changes and settle obsolete queued requests during replacement and shutdown.
* feat(plugins): support modal body layout, scrolling and clipboard actions
* fix(app): keep native scroll dependencies out of web imports
* fix(nix): sync dependency hash for plugin native types
* test(plugins): express modal QA as user journeys
The startup override replaced the bundle icon with a flat PNG even in packaged builds. Limit the override to development so macOS retains the bundle icon appearance.
* feat(plugins): contribute settings screens and shared controls
* fix(plugins): localize settings and verify shared UI consumers
* test(composer): unmount draft test roots after each case
* test(app): keep launch terminals alive until cleanup
The fixed ten-second profile lifetime could expire before a busy CI browser attached or finished revisiting the composer. Keep the real process waiting for input until project teardown terminates it.
* test(server): await the initial snapshot before a buffered ref event
Watcher setup can read checkout facts before the initial refresh publishes a snapshot. Wait for publication before injecting the event so the test consistently exercises a second refresh.
* feat(hub): continue agents through standard daemon operations
Authorize ordinary agent and workspace recovery operations with hub.execute. Keep conversation routing in Hub and persist generic creation and message receipts so retries cannot silently duplicate work.
* fix(hub): expose custom providers to continuing clients
Allow Hub to advertise provider support independently of the app version. Document the companion continuation policy and migration. Preserve newly observed files in the native watcher inventory so coalesced deletions remain visible to reconciliation, addressing the Windows CI failure.
* fix(hub): allow retries after confirmed local failures
Prepare message loading before recording dispatch intent. Remove failed creation receipts only when creation cleanup completed and no live or stored agent exists. Keep ambiguous provider deliveries fail-closed. Exercise native file observation with real filesystem dependencies.
* perf(app): restore live sessions immediately after resume
Foregrounding now reconnects every host without waiting for scheduled backoff, while cached turns, timelines, agents, and workspaces remain internally consistent until authoritative catch-up arrives. Replica persistence is keyed and incremental so ordinary updates avoid whole-directory serialization.
* fix(app): preserve turn ownership through session updates
Stream turn closure now wakes queued-message delivery, while same-turn snapshots retain local cancellation state. Offline liveness remains visible until an authoritative close arrives.
* fix(app): preserve pending chat cache writes and trim resume refactor
Directory baseline replacement must preserve independently accepted timeline changes until the deferred flush. Keep the reconnect and persistence fixes while removing the session-data facade and broad read-hook migration.
* fix(app): initialize reconnect policy from current app state
Initialize visibility before host bootstrap so a background mount cannot leave reconnect enabled. Seed sidebar fixtures through the workspace owner so cache hydration preserves their rows.
* test(app): verify submission settlement across reconnect
Keep submission acknowledgement separate from turn liveness: disconnect preserves the running turn until authoritative catch-up. Wait for confirmed running chrome before exercising queued cancellation so the alternate-send shortcut cannot race turn startup.
* docs: remove cache implementation recap
* feat(terminals): expose workspace ownership through the SDK and CLI
Directory-based terminal creation selected the oldest workspace sharing that path. Expose explicit workspace ownership in the shared public API and CLI, retain ownership and process directories in listings, and acknowledge terminal deletion after teardown.
* fix(file-observer): preserve nested shallow reconciliation scopes
A parent directory scan does not visit its descendants. Collapsing change scopes discarded nested scans and left coalesced native deletions unseen until a full audit.
* test(terminals): extract output waits from end-to-end scenarios
* fix(server): keep agent reloads from overlapping session writers
Idle runtimes can still own their persisted session writer. Release the original runtime before resuming and retain failed cleanup for retry; a timeout must not authorize another writer.
* fix(server): honor closes queued during agent reload
Serialize public close with reload so a completed close cannot be undone by replacement registration. Keep archive cleanup inside its existing lifecycle operation.
* fix(server): filter empty ACP import sessions
ACP metadata probes can leave persisted sessions that later appear in import results. Replay available session history to exclude truly empty entries while retaining sessions whose history is unavailable, and close probe sessions before process teardown.
* fix(server): bound ACP session history loading
Keep import enrichment inside the provider listing deadline when session histories are inaccessible. Catalog abort cleanup now also waits briefly for an in-flight session creation so the resulting probe session can be closed.
* test(server): avoid wall-clock ACP assertion
Verify the history budget through the number of attempted protocol loads instead of elapsed process time, so scheduler delays cannot make the regression flaky.
* fix(server): allow slower ACP history loads
Give native session history more time to replay while retaining a shared enrichment budget below the provider listing deadline.
* fix(server): give ACP history replay enough time
Use the provider RPC timeout scale for native history loading, with a larger shared budget and provider-list deadline. Keep timeout coverage fast and deterministic with a controlled clock.
* fix(server): isolate generous ACP import deadlines
Keep the long listing deadline for the picker’s single-provider requests while preserving the short aggregate deadline for callers that fan out across providers. Restore deterministic coverage of unresolved history loads through fake timers and an injected clock boundary.
* fix(server): preserve ACP aggregate listings
* fix(subagents): preserve recursive provider ownership
Provider events retained enough lineage to identify nested work, but the canonical descriptor discarded the direct owner. Preserve that relationship at ingestion so child timelines and hierarchy remain attached to the process that emitted them, including after history replay.
* test(e2e): clarify nested ownership journey
* test(e2e): separate nested ownership actions
* fix(subagents): retain nested ownership across replay
Wait and interaction events can reference descendants without spawning them. Preserve the original spawning owner and route persisted background notifications through that same relationship after history reload.
* test(subagents): hide provider turn plumbing
* fix(subagents): scope nested tracks to their host
Plugin-provided icons are host-specific. Carry the panel host through nested child tracks so the rebased ownership UI resolves the same provider presentation as its parent.
* fix(workspaces): preserve launched terminal focus
A provider configuration edit invalidated every cached catalog and could turn healthy providers into false availability errors under the resulting probe burst. Preserve unchanged registry entries, clients, loads, and snapshots, and keep the submitted draft snapshot stable while creation is pending.
* fix(desktop): restore macOS app updates
Electron 41's Squirrel helper can leave the update handoff dormant on current macOS, so Paseo quits cleanly but relaunches the old bundle. Electron 44 wakes ShipIt through its XPC service.
Include updater lifecycle events and ShipIt state in app diagnostics so future handoff failures identify the current version, target version, cache paths, and installer output.
* fix(desktop): harden updater diagnostics
Keep each ShipIt file's evidence available when another read fails, carry the service-selected version into updater lifecycle logs, and make clean workspace installs resolve the Electron runtime used by desktop tests.
* refactor(desktop): name updater install options
* refactor(desktop): name updater log payloads
* refactor(desktop): name clipboard payload
* fix(desktop): surface updater lookup failures
* test(desktop): await guest focus transition
* fix(desktop): guard macOS update compatibility
* test(release): validate desktop manifests before publish
* test(desktop): await populated daemon pid file
Allow trusted local plugins to register complete coding-agent providers through the existing agent lifecycle. Includes direct and ACP implementations, SVG provider icons, custom timeline items, runnable examples, and public authoring documentation.
Closes#3547Closes#3556
Scope provider tool-call identity to its autonomous turn so repeated IDs remain distinct, lifecycle updates still merge correctly, and native timeline lists no longer receive duplicate keys.
The provider refresh deadline aborts a Codex catalog probe by disposing
the app-server client, but dispose killed the child without settling
in-flight requests. With the request fallback timeout at 14 days, an
unanswered `initialize` left `fetchCatalog` pending forever, so the
workspace snapshot entry stayed "loading" and the model picker showed
"Loading" for every Codex profile with no timeout ever surfacing.
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
* feat(pi): surface extension notify in the agent timeline
Pi extension ctx.ui.notify() messages were dropped or buffered away
and never appeared anywhere in Paseo. Emit them as notification
timeline items so they show in the agent chat feed, with
info/warning/error levels mapped to activity log styling.
* fix id
* feat(agents): unify timeline notifications
Represent Pi and OpenCode notices as one first-class timeline item instead of provider-specific synthetic tool calls. Gate the new wire variant so older strict clients keep working during version drift.
* test(omp): update notice expectations
---------
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
* perf(app): smooth mobile keyboard transitions
Animating dock padding forced Fabric and Yoga to relayout the chat tree on every keyboard frame, producing visible stepping and React list churn. Keep the surface full-height and move it as one translated unit, with scroll range and portal measurements updated only after keyboard motion settles.\n\niOS keeps its native keyboard animation driver behind the same component boundary because its Reanimated signal reports the endpoint at animation start.
* fix(app): isolate native keyboard implementation
Add backend-only exact-provider controls for the Paseo tool catalog across native and MCP-backed agents, including persisted configuration, session snapshots, public documentation, and regression coverage.\n\nCo-authored-by: Matt Cowger <matt@cowger.us>
Hub execution credentials need provider snapshot access so remote workflow editors can present the daemon's native models, modes, and thinking options without gaining broader daemon read authority.
Keep stable v0.7 guidance available while the client-entry layout is previewed for v0.8. Existing reference and migration URLs redirect to the release that owns their content.
The fixed header overlay repainted after compositor-driven scroll, so ordinary headers visibly lagged behind the diff body. Paint in-flow headers with the scrolling canvas and reserve a bounded sticky canvas pool for push-off behavior.
* feat(cli): accept host as a global option
Keep the command-local position for compatibility while letting users place one daemon target before the command and reuse it while changing verbs. Local daemon lifecycle commands continue to operate locally.
* fix(cli): preserve project host resolution boundary
Project creation already handled PASEO_HOST directly. Avoid routing that established behavior through a newly exported client helper so its isolated command tests keep their existing dependency boundary.
* test(cli): allow scaffold typecheck under CI load
* test(cli): provide remote cwd in schedule e2e
* docs(cli): teach only global host syntax
* fix(desktop): stop retaining closed Settings views
Settings rotations left detached detail panes reachable from the web style registry's strong element sets. Backport the upstream weak cleanup design and cover the real Electron bundle with a forced-GC regression.\n\nKeep the established sibling-route topology; compact Back relies on route-name identity.
* test(app): synchronize Mermaid completion layout assertion
The streaming acceptance test can observe the intentional completion remount between visibility and layout sampling. Consult the existing completion promise around that measurement so the transition is not reported as diagram loss.
* test(app): isolate worktree restart from provider startup
* feat(workspaces): gate fork repository automation
Cross-repository change requests can execute contributor-controlled package hooks even when the setup command comes from the base repository. Persist the checkout provenance and require one explicit workspace-level setup action before repository automation runs.
* fix(workspaces): clarify blocked script errors
* fix(app): keep long toasts readable
* test(server): expect fork repository provenance
* test(server): make setup marker portable
* test(app): retry transient sidebar layout
* fix(app): restore diff copy actions on web
The canvas-backed diff owns selection without creating a DOM range, so browser context menus cannot expose copy actions themselves. Route keyboard and context-menu actions through the renderer's canonical source selection instead.
* fix(app): harden web diff copy actions
* fix(app): default split header selection to new pane
* fix(app): isolate nested diff context menus
* fix(app): select available split diff pane
* feat(plugins): phase 1 split runtime entries after green gate
The compiler now builds explicit client and server entries, so runtime boundaries are source-owned instead of registration-name filtering. Compiler and runtime tests prove the entry split, directory and suffix boundaries, client Node import errors, migration failure, and client-only operation without a subprocess.
* feat(plugins): phase 2 run client entries after green gate
Explicit client and server contexts replace the mixed plugin context. The app runner owns every client registration and its idempotent removal, including late contributions and composer pills; focused contribution tests prove the removal contract, and all five migrated examples reached running on the isolated worktree daemon.
* feat(cli): phase 3 scaffold runtime split after green gate
The generated project demonstrates the required shared RPC contract, server handler, client surface, and sidebar wiring. The scaffold test and an isolated init, typecheck, install, and running check prove the phase acceptance path.
* docs(plugins): phase 4 publish migration after green gate
Gate: current docs and the paseo-plugin skill describe only explicit client/server entries. The standalone migration guide maps every former registration, and migration-doc.test.ts proves every client add* method remains represented.
* fixup! feat(plugins): phase 1 split runtime entries after green gate
Gate: compiler 13, runtime 24, typecheck, lint, and format passed. The dependency fixture proved node_modules/client was incorrectly treated as a plugin boundary.
* fixup! feat(plugins): phase 2 run client entries after green gate
Gate: client runtime and registry tests, typecheck, lint, format
* fixup! docs(plugins): phase 4 publish migration after green gate
Gate: migration docs test, paragraph audit, typecheck, lint, format
* fixup! feat(plugins): phase 1 split runtime entries after green gate
Gate: compiler organization tests, typecheck, lint, targeted format
* fixup! feat(plugins): phase 2 run client entries after green gate
Gate: shared SDK tests, runtime tests, typecheck, lint, targeted format
* fixup! feat(cli): phase 3 scaffold runtime split after green gate
* fixup! docs(plugins): phase 4 publish migration after green gate
* fixup! feat(plugins): phase 1 split runtime entries after green gate
* docs(plugins): rewrite public plugin docs for runtime entries and mobile guardrails
* fixup! feat(cli): phase 3 scaffold runtime split after green gate
The scaffold now keeps DOM globals out of the program and declares only window.open inside client/web.ts. The scaffold test invokes tsc in a fresh process, proves the generated project passes, and proves a stray document access fails.
* fixup! docs(plugins): phase 4 publish migration after green gate
The internal guide and plugin skill now forbid both the DOM lib and triple-slash DOM references. They direct web adapters to declare only the globals used by client/web.ts.
* fixup! feat(plugins): phase 1 split runtime entries after green gate
Removing the obsolete Babel parser changes the locked npm dependency graph. The macOS Nix desktop gate reported the new fixed-output hash, which this commit records.
* fixup! feat(plugins): phase 1 split runtime entries after green gate
Reject relative imports that escape the plugin root while continuing to skip resolved node_modules internals. The compiler regression test proves the escaped import is rejected and the dependency-internal fixture remains accepted.
* fixup! feat(plugins): phase 1 split runtime entries after green gate
Classify absolute imports and reject plugin-authored paths that escape into node_modules. Dependency internals remain exempt based on their importer path. Red-first compiler regressions cover both bypasses, and the existing dependency fixture remains green.
* fixup! feat(plugins): phase 1 split runtime entries after green gate
Classify canonical esbuild resolution results so dependency-relative and symlink imports cannot escape client/server boundaries. The two compiler regressions failed before the fix and pass afterward.
* test(ci): synchronize flaky state transitions
Wait for repository watcher registration before emitting buffered ref events, let sidebar order polling retry unlaid-out rows, and wait for the inactive browser parking state before screenshot capture. These changes directly address the three observed CI failures; the affected server, Playwright, and desktop browser tests pass locally.
* test(ci): synchronize Mermaid completion layout assertion
The Playwright streaming acceptance test exposed a completion remount between visibility and layout sampling. Check the existing completion promise around the measurement so that transition is not reported as diagram loss.
* fixup! feat(plugins): phase 1 split runtime entries after green gate
Resolve bare package imports before boundary classification so symlinked dependency entries cannot expose server modules to the client bundle. The focused regression proves the bypass and the compiler file passes 20/20.
* fixup! feat(plugins): phase 1 split runtime entries after green gate
Allow canonical paths only within a matching linked package root while preserving runtime and containment checks for imports that leave it. The linked-dependency regression fails before the fix and compiler tests pass 21/21 afterward.
* fixup! feat(plugins): phase 1 split runtime entries after green gate
Reject matching package manifests that contain the plugin or live inside it, so linked-dependency roots cannot exempt plugin or workspace files. The ancestor-manifest regression fails before the fix and compiler tests pass 22/22 afterward.
* fixup! feat(plugins): phase 1 split runtime entries after green gate
Validate plugin-local lexical boundaries before canonical linked-root exemptions and require remembered-root imports to originate inside that root. The linked-root bypass regression fails before the fix and compiler tests pass 23/23 afterward.
* feat(app): add fullscreen viewer for Mermaid diagrams on web
Add a fullscreen action to the diagram toolbar that opens the diagram in
a modal ZoomableViewport with free zoom/pan, closable via the close
button, Escape, or clicking outside the diagram. The sandboxed iframe
runtime moves into a shared MermaidIframeRuntime so the inline host and
the fullscreen viewer drive the same renderer.
Fixes uncovered while verifying the feature end to end:
- The runtime measured the rendered SVG with getBoundingClientRect(),
which depends on the iframe size the host derives from that very
measurement. Each streaming re-render shrank the reported size by the
diagram box padding, ratcheting diagrams down to a few pixels. Measure
the viewBox instead, which is the diagram's own size, and let the SVG
fill whatever frame the host provides.
- ZoomableViewport's root is flex: 1, so as a flex item its 0% basis
overrode the inline diagram box height and the box collapsed, scaling
the diagram tiny and clipping the toolbar unclickable. Size the box
from its height and keep a minimum tall enough for the toolbar.
- react-native-web's Modal focus trap focuses the first descendant that
takes focus, which was the diagram iframe; every keystroke then landed
in the sandboxed frame and Escape never reached the window listener.
Mark the presentational iframe inert and focus the viewer's content
layer on mount.
Closes#4106
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(app): exercise Mermaid fullscreen interactions
Cover zoom, pan, outside-click dismissal, and streaming layout through the real browser path. Remove the mocked DOM test that duplicated those behaviors without exercising the viewport or iframe runtime.
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
The homepage download CTA was chosen by a client-only hook that started at
"mac-silicon" and corrected itself in an effect, so the server-rendered HTML
said "Download for Mac" for every visitor and only became right after
hydration. On a phone that window is long enough to photograph. Worse, the
detection had no mobile branch at all: Android user agents also say "Linux",
so once hydration did land, a Pixel was offered an AppImage.
Resolve the platform from the request user agent during SSR instead, so the
first paint is already correct. Mobile visitors get the App Store or Play
Store as the primary action and a link to /download for the desktop build,
rather than a .dmg they cannot open.
The markup now varies by user agent, so HTML and server-function responses
carry `Cache-Control: private, no-store` and `Vary: User-Agent`. An absent
cache-control is not no-store — a shared cache may assign heuristic freshness,
and turning on Workers Cache would store a headerless 200 for two hours under
a key that ignores the user agent, letting one Android visitor pin the Play
Store button for every Mac visitor after them.
Also tighten the mobile layout the wrong CTA was sitting in: the primary
action gets its own row so the icon buttons stop orphaning one of themselves,
provider cards drop to a two-column grid, and the hardcoded mobile line break
in the phone showcase gives way to a balanced measure.
* feat(app): make import session a first-class entry point
* feat(server): improve import session discovery resilience
Search up to 500 persisted candidates per provider and isolate listing failures behind eight-second deadlines so healthy results remain usable. Preserve native persisted title precedence while keeping discovery read-only.
Parsing now continues through each transcript so native titles and the true last prompt are retained; the old early exit made lastPromptPreview equal firstPromptPreview. A cheap record prefilter avoids JSON.parse for unrelated lines during the full scan.
* feat(app): searchable, grouped import session sheet with per-provider errors
The sheet now carries a debounced search field gated on the host's
importSessionSearch feature, grows its per-provider page on demand
(15 -> 45 -> 90 -> 200) instead of capping at the 15 most recent, and groups
unscoped rows by folder under the project name the app knows for that
directory. Worktrees of one project would otherwise all head up as the same
bare name, so a directory below its project root carries the path under that
root beside the name.
A workspace-scoped sheet says so and offers "Show all sessions". Importing a
row from another directory once that is on drops the workspace from the
request, which the daemon would otherwise reject, and routes the user to the
agent's own workspace rather than opening a dead tab in the current one. That
decision lives in resolveImportTarget: a scoped listing is trusted wholesale
because the daemon already matched it realpath-aware, which the client cannot.
A provider that fails now gets its own error row with a Retry that refetches
only that provider, sourced from both a failed request and the daemon's
providerErrors. React Query no longer retries these, so the header spinner
settles once every provider has answered or given up (#2512).
* test(e2e): import session flow with fixture Claude sessions
* fix(app): shorten import sheet scope copy so it fits at phone width
At 390px the scoped subtitle rendered "Sessions in this wor..." — the label,
its action, and the sheet's refresh and close buttons do not share one line at
phone width. The label already sits under the sheet title "Import session", so
"Sessions in this" was repeating context the header had: it becomes "This
workspace" and the action becomes "Show all". The unscoped label still names
the host, which nothing else on the sheet does.
* refactor(app): flat recency-ordered import list with per-row folder label
Grouping by folder buried recency: a busy directory's block of fifteen pushed
a two-minute-old session in a quiet directory below the fold, and the axis a
user is scanning on is when they last touched the session, not where it lives.
The unscoped sheet is now one list in the order aggregateSessionEntries already
produces, and each row carries its folder as a muted third line.
resolveDirectoryLabel stays — it is what keeps two worktrees of one project
apart — and formatDirectoryLabel renders its two parts as the single ellipsised
line a row has room for. A scoped sheet lists one directory, so its rows carry
no folder line at all.
* fix(app): close import sheet before refreshing sessions
Provider-list invalidation waited for every active query before the sheet could close. Mark the disabled picker queries stale after close and navigation so reopening refreshes without delaying the imported agent.
* fix(server): reuse workspaces for session imports
Host-wide imports should join the oldest active workspace for their directory, or restore its archived workspace, instead of minting duplicates. Rollback remains limited to workspace records created by the failed import.
* test(e2e): express the import session journey through a helper DSL
* test(e2e): align sidebar journeys with import entry point
* feat(providers): add Gajae Code to the ACP provider catalog
Gajae Code (gjc) ships a conformance-tested ACP agent (`gjc acp`), so it can be
a one-click catalog entry instead of a hand-written custom provider in
config.json. Command and env match what the agent's own installer writes.
* feat(providers): add Gajae Code icon
---------
Co-authored-by: Yeachan-Heo <yeachan.heo@gmail.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
* feat(website): publish Hub pricing options
Present self-hosted and hosted Hub choices together while reading the hosted offer from Hub's public catalog.
* fix(website): require a valid hosted offer
* fix(website): preserve Hub page on catalog failure
Join and truncate prompt-derived titles for the visual tooltip, add agent ID and live human activity metadata on a second row, and keep activity subscriptions local to the open tooltip. Preserve the full normalized title as the accessible tab name and cover the behavior through Playwright.
Drive the streaming assertion from the actual turn lifecycle so iframe replacement at completion is not mistaken for diagram loss. Preserve the cached timeline range during reload reconciliation so a projected message spanning the cache cursor replaces its partial prefix instead of being appended to it.
A repository-controlled core.fsmonitor command can execute when the daemon refreshes Git state. Opening or reconciling a malicious checkout therefore allows code execution as the daemon user. Force core.fsmonitor=false on daemon-owned Git invocations before repository config is considered.
Plugins can transform live timeline source items at render time, append durable plugin rows from daemon handlers, and register client-only slash commands in the composer.
Related to #3940 and #3952.
* fix(app): keep Changes comparison and diff in sync
Own the working comparison at the workspace checkout so the Changes tree and adjacent working diff share it. Immutable commit diffs remain scoped to their commit SHA.
* docs(app): align working diff ownership notes
Non-AppImage packages install the Electron executable at a stable path, but invoking it through the CLI symlink cannot restart the daemon. Link the bundled CLI shim for those targets while preserving AppImage links.\n\nFixes #4184
* feat(app): customize top-level sidebar items
Let users reorder and hide built-in and plugin sidebar rows from Appearance settings while keeping the preference device-local. Add Search as a first-class row and standardize section explanations as header info tooltips.
* fix(app): preserve consecutive sidebar preference edits
Derive each sidebar change from the latest cached settings and retain unavailable plugin slots while editing the visible list. Keep E2E interaction selectors tied to accessible control names.
* test(app): isolate provider settings unit boundary
Keep the legacy provider-section test focused on provider behavior now that its shared section shell can render the settings info control.
Create tag-triggered GitHub releases as drafts and publish them only after the desktop updater manifests are complete, stamped, and uploaded. Preserve beta prerelease semantics and safe retry behavior.\n\nCloses #4183
* fix(app): activate mobile panels after motion settles
Gesture previews were publishing React presentation state and activating retained content before the native motion finished. Keep transient motion on the UI thread and publish the active panel only from the accepted animation completion.
* perf(app): re-render only changed history rows
Every coalesced stream tick re-rendered all mounted history rows because the inverted FlatList recreates each cell with a new index and ref. Keep layout item identity for unchanged rows and memoize the row on it, and resolve tool-call groups through a stable event so the render callbacks stop churning.
* perf(app): count replica row bytes once
Budget accounting measured every stored row of a touched host on each persist through the Buffer polyfill. Cache the UTF-8 length per immutable row and count without materialising bytes.
* fix(app): settle mobile panels on position
Activity now follows the UI-thread position reaching the canonical anchor instead of an animation completion callback. A successful gesture keeps its finishing motion and the matching command adopts it; settlement and command acceptance may arrive in either order.
* fix(app): keep settled panel props across JS stalls
Turn off Reanimated's FORCE_REACT_RENDER_FOR_SETTLED_ANIMATIONS. Its settled-props collector drops entries older than two seconds before React receives them, so a JS stall across a settle left the overlay display and backdrop opacity reverted on the next commit while the store held the new panel.
* fix(app): reconcile hidden keyboard end on iOS
Only a hidden end event resets the keyboard shift. A late visible end no longer resurrects a keyboard that already dismissed.
* fix(app): focus the replacement input through autoFocus after clearing
Clearing swaps the native input for a fresh instance. Focusing the doomed instance asked Android for the keyboard and then tore the focused view down, which cancelled the show; a JS focus command sent alongside the remount reached a view that was not attached yet and the IME ignored it. Focus and blur requested while the replacement is pending now decide the replacement's autoFocus, which native applies once the view is attached.
* fix(app): revise history rows in every viewport
The history row memo boundary bails out until its item identity changes, but only the native viewport cloned rows flagged by historyRowRevision. On web and desktop a history host whose tool-call group kept updating from the live head rendered stale. Move the revision into useRevisedHistoryRows and run both viewports' history through it. Regression test renders the web strategy with a memoized row and asserts only the revised host re-renders.
* perf(app): keep large diffs responsive
Render file headers on the canvas and bound expensive text shaping to the active viewport while preserving sticky, hover, and horizontal-scroll behavior. Let native scroll cancellation arbitrate header presses so finger scrolling cannot expand files.
* test(app): assert canvas diff header accessibility
* test(app): assert canvas diff header semantics
System-injected prompts have canonical turns but no visible user row, so completed timing was discarded despite an exact end timestamp. Preserve completion metadata with an explicitly unknown duration so the footer can show the truthful timestamp.
* feat(claude): add Fable 5.1 model
Expose the new 1M-context model through the curated Claude catalog and normalize its dated and context-suffixed runtime IDs back to one canonical entry.
* fix(claude): group Fable models in catalog
* chore(release): cut 0.7.1
* fix(nix): refresh 0.7.1 dependency hash
* test(cli): follow package version in plugin scaffold
* test(cli): keep Claude catalog checks extensible
* fix(ci): eliminate recent cross-platform flakes
Synchronize tests on owned state instead of elapsed time or transient identities. Preserve file rename collision safety when platform file IDs are unavailable.
* fix(ci): close follow-up synchronization gaps
Keep fetches in flight through buffered-event classification, scope mock agents to navigation tests, and wait for live workspace hydration before opening agent routes.
* fix(ci): synchronize browser lifecycle boundaries
* fix(app): cap oversized assistant messages
A single assistant message can expand into enough rendered Markdown nodes to crash the renderer. Limit the Markdown input while preserving the complete timeline value and report its full byte size.
* fix(app): bound oversized stream processing
Cap the render input before paced reveal and defer full-message byte counting until completion. Reuse the existing grapheme boundary policy so capped text never ends in a partial glyph.
* fix(app): preserve capped text without segmentation
Hermes lacks Intl.Segmenter, while the shared reveal boundary intentionally returns zero without it. Keep that contract and fall back locally to a surrogate-safe 32K prefix.
The shared image lightbox handled Escape after the global shortcut dispatcher had already routed the same key to agent interruption. Register the viewer with the established web overlay owner so dismissal takes first refusal while ordinary Escape interruption remains intact.
* feat(plugins): support prepared Git installations
Let Git plugins declare explicit preparation commands without making package installation the default. Keep installation transactional, make plugin trust explicit, and use one canonical source reference for monorepos.
* fix(plugins): parse Windows source references
* fix(server): keep daemon alive when JSONL RPC stdin hits EPIPE
A dead Claude/OMP child currently turns stdin.write() EPIPE into
uncaughtException and kills the daemon. Catch the write, listen for
stdin errors, and close that transport instead.
Closes#4047
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* test(server): reproduce JSONL RPC stdin EPIPE
* test(server): gate native EPIPE fixture on POSIX
---------
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
* fix(android): keep timeline still when tapping text
Selectable Android text takes touch focus. React Native then scrolls focused children using untransformed coordinates, which moves inverted timelines even though the user did not scroll.\n\nSkip that auto-scroll only for touch-focused, selectable, non-editable text in inverted vertical scroll views, preserving normal focus navigation and editable inputs.
* test(e2e): isolate PR pane fixtures from bots
Track each foreground run start as pending, started, or failed so a previous turn error cannot reject a newly accepted prompt. Includes a regression for resuming after a failed turn.
Keep mobile interface chrome at 15px while defaulting readable content to 16px. Migrate existing mobile 15px content preferences once while preserving later explicit choices.
* feat(hub): make daemon authority explicit
Connecting a daemon establishes presence without granting execution. Hub authority now uses semantic session permissions, with workflow execution granted explicitly and inspectable through the normal session protocol.
Attention frames remain part of the existing agent and terminal subscriptions, keeping session output solicited.
* test(server): match sentence-case tool labels
Allow the composer constraint chain to shrink inside KeyboardDock so overlong drafts scroll internally without hiding controls behind the IME. Extend the Android keyboard invariant to cover the maximum-height case.
Stopping capture abandoned audio focus but left Android's communication mode and selected device owned by Paseo, which could keep Bluetooth audio on its narrow-band call route without an active microphone. Release and reacquire the complete route with the audio session, and prevent idle device callbacks from restoring it.
Present Paseo-owned prompts, inputs, and results as readable provider-neutral sections with sentence-case labels, backed by focused tests and real-provider UI verification.
Task snapshots reuse identities after completed work, so backward status transitions do not prove that an old task resumed. Report active work as started and keep pending work in the current task snapshot instead of inventing a reopened event.
The keyboard-sync change needed submission blur to dismiss the native keyboard, but applying it to every platform interrupted consecutive keyboard-driven messages on web and desktop. Keep submission blur native-only.
Mid-stream silence clearing can leave a non-final partial whose segment will never commit. Treat those partials as UI-only once all requested commits settle so retries of the same recording can complete instead of timing out forever.
Android reports one terminal pinch update with a stale two-pointer count after a finger has lifted. Track the real touch lifecycle so that shifted focal point cannot move the image on release.\n\nKeep initial presentation padding separate from the full zoom canvas, and make double-tap reset and outside-image dismissal consistent across native and web.
Chat image previews render in a native Modal outside the app gesture root, so their controls worked while pinch gestures never activated. Give native lightboxes their own gesture root, keep the web modal on a normal view root, and ignore Android's terminal one-pointer pinch update to prevent release jumps.
Fix native wide-layout model selectors mounting bottom-sheet scrolling outside a bottom sheet. Keep the scrolling policy with the model browser and cover native compact, native wide, and web surfaces.
Keep trusted-client and Hub behavior stable while replacing RPC-name grants with a semantic authorization boundary. This establishes the operation inventory needed for future principal, pairing, and resource-scoped access work.
xAI moved Grok accounts to unified billing. The plain /v1/billing
response now returns a zeroed legacy monthly shape (monthlyLimit.val 0),
which rendered as "0 credits left" with no bar in Settings -> Usage.
Fetch /v1/billing?format=credits — the same query the Grok CLI /usage
command uses — and map creditUsagePercent + currentPeriod onto a usage
window, matching the Claude/Codex weekly window convention. The legacy
monthly-credits balance path is unchanged; unified-billing responses
omit monthlyLimit/used, so it naturally yields no balance.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Image files now zoom in place, while timeline images open the existing lightbox. A shared viewport extracted from Mermaid keeps fit, focal zoom, pan bounds, and consumer toolbar actions consistent across content types.
* fix(opencode): reuse servers across agent sessions
Paseo's always-present per-agent environment forced every OpenCode session onto a dedicated helper, multiplying cold starts and memory use. Route ordinary sessions through one shared helper while a daemon-owned session bridge preserves exact environment and caller-scoped tools; keep custom process environment and MCP configuration isolated.
* fix(build): bundle the agent bridge from existing dependencies
The helper package duplicated Zod and changed the fixed-output desktop dependency graph even though its tool wrapper was identity-only. Use the server's existing Zod runtime directly so packaged daemons can materialize the bridge without extra traced dependencies.
* refactor(server): isolate provider runtime composition
Keep provider-specific lifecycle and tool-catalog wiring behind an agent-owned runtime boundary so the generic daemon bootstrap remains provider-agnostic. The runtime now owns bridge startup, snapshot injection, and idempotent cleanup.
* fix(desktop): prebundle the agent bridge plugin
Packaged daemons run from an application archive where esbuild cannot spawn its binary. Produce the self-contained plugin during the server build and make compiled runtimes read that artifact; source-mode tests retain a gated compiler fallback.
* fix(app): preserve archived agent navigation
Make explicit archived-agent opens one atomic layout command and defer them until persisted layout hydration. Use the provider's numeric active-session sentinel so recovery can complete.
* fix(app): preserve timeline resume during hydration
Paint persisted canonical rows with the current live head and retain their resume cursor. This prevents tail replay without dropping stream updates that arrive while replica storage loads.
* fix(app): serialize replica reads with cache writes
* fix(app): keep chat synchronized with keyboard
Dock the complete chat surface with worklet-driven layout padding so visual and native touch geometry move together. Reset cleared multiline native inputs and keep autocomplete portals anchored to the docked composer.
* fix(android): tolerate detached composer inputs
Return the nullable base input connection during IME restart instead of force-unwrapping it after the composer has blurred or unmounted.
The initial retained poll could run before repository identity discovery settled, routing every workspace through the legacy per-target path. Defer each unresolved target independently and rejoin the shared polling grid once its identity is ready.
MCP and extension initialization can keep provider control-plane requests pending beyond 30 seconds even when the response will arrive. Use a configurable 60-second provider default and include the active RPC phase in timeout errors.
The bottom-anchored commits section did not consume the device inset, so Android system navigation covered its disclosure row. Keep the inset with the section that owns that edge.
Eight actions were only reachable through the sidebar workspace menu,
the workspace header menu, or a keybind you had to remember: Rename
workspace, Copy workspace path, Copy branch name, Pin/Unpin, the three
layout toggles, and Show setup. All eight are now in the command
center, plus a new one: label assignment. Type a label's name to
assign or unassign it on the active workspace without leaving the
keyboard.
Rename needed more than a palette entry. Its dialog lived inside the
sidebar row component, so it disappeared whenever that row was not
rendered: a collapsed project or status group, a collapsed Pinned
section, or focus mode. It now has a global host keyed on the active
workspace selection, mirroring use-global-workspace-pin-action.ts.
Each entry reuses the handler the existing menu item already calls, so
behaviour matches the menu. Entries that cannot act are omitted rather
than present and failing: Copy branch name without a branch, Pin when
the host lacks workspacePinning, Show setup when there is nothing to
show, and labels when the catalog has not loaded.
Toggle Explorer sidebar and Toggle focus mode are registered as
workspace contributions, not global ones. Their handlers live in
workspace-screen.tsx behind an isRouteFocused gate, so a global
registration would have listed two entries that close the palette and
do nothing on /settings, /sessions, /schedules and Home.
The copy-path, copy-branch, and rename-dialog logic was duplicated
across three sidebar files. It moves into one hook and one component so
the palette is not a fourth copy. That also fixes drift in
sidebar-status-list.tsx, where Copy branch name copied the workspace
name instead of the branch and four strings were hardcoded English.
This supersedes #3383, which fixes the same bug directly without the
shared-hook refactor.
Toggle focus mode also had a hidden duplicate: buildPaneContributions
already dispatches the same action once split panes are available, so
the standalone entry now steps aside there instead of listing the
toggle twice.
No new i18n keys.
Add transport-only SSH connectivity for the CLI and Electron desktop through the user's OpenSSH configuration. Remote installation and daemon lifecycle remain explicit, separate operations.
Preserve actionable SSH failure details when the proxy socket closes before the SSH child exits.
Closes#3949
Co-authored-by: James Strain <jstrain@nvidia.com>
Co-authored-by: Reid Levesque <reidlevesque@users.noreply.github.com>
* fix(app): stop workspace tab reconciliation loop
Duplicate startup tabs could move workspace focus into Explorer. Once hydration removed the stale agent, empty-workspace reconciliation kept seeding drafts into Explorer and retriggering itself. Preserve ordinary-pane focus at the reconciliation boundary.
* fix(app): normalize workspace focus before reconciliation
Persisted Explorer focus must not steer newly reconciled tabs into the sidebar before the output invariant is applied.
* fix(dictation): preserve audio queued at stop
Stopping capture disabled native recording before the final microphone buffer had crossed the bridge, so accepting immediately could omit the end of a dictation. Wait for the native producer to publish its final buffer before completing stop, and serialize stop with restart and destruction.
* fix(voice): serialize capture lifecycle
A capture start already awaiting permission could resume after teardown, and repeated destroy calls did not share completion. Order capture lifecycle operations behind one promise and build the native module declarations before clean-CI typechecking.
* fix(dictation): drain queued web capture before finish
* fix(dictation): await in-flight segment commits
* Revert "fix(dictation): drain queued web capture before finish"
This reverts commit 5168a6554d.
* Revert "fix(voice): serialize capture lifecycle"
This reverts commit 00fd3e3556.
* Revert "fix(dictation): preserve audio queued at stop"
This reverts commit ec087dc9cd.
Paseo forced signing off for commits created through the app and squash merges. Let Git apply the user's configured policy; the existing command timeout still bounds interactive signer waits.
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Durable replica reads could race accepted directory replacements and restore rows that had already been removed in memory. Serialize reads behind pending writes and discard reads when the host changes in flight; local archives also suppress targeted cache restoration until removal is acknowledged.
* feat(plugins): add React Native UI primitives
Give client plugins host-owned adaptive UI without exposing app internals. Preserve plugin runtime contexts when modal content moves through the compact portal host.
* docs(plugins): clarify host UI reference
* refactor(plugins): harden host UI boundary
Replace mocked pass-through coverage with a real browser plugin journey, keep host runtime ownership inside the evaluator, preserve toast context across compact portals, and expose modal content as a compound component.
* fix(plugins): defer native UI presentation runtime
Loading the plugin evaluator initialized the modal and toast presentation stacks even when no plugin requested host UI. Keep the capability stable while delaying platform-heavy presentation code until Modal.Content renders.
* refactor(plugins): move modal metadata to root
Keep Modal.Content body-only while the top-level Modal owns the host-rendered title and leading icon. This leaves presentation metadata stable as the compound API gains future regions.
* Batch GitHub PR status polls into aliased GraphQL requests
The PR status poller ran one `gh pr view` (GraphQL-backed) plus a
per-PR facts query for every workspace on its own timer, so cost
scaled linearly with workspace count and exhausted the 5,000 pts/hour
GraphQL quota machine-wide past ~50 workspaces (#3587, #2470).
Replace the per-target timers with a global scheduler that snaps poll
targets to a shared 5s grid and flushes all due targets together:
- One aliased discovery query per batch of up to 25 targets (grouped
by GH host) resolves PR identity, state, mergeability, and the merge
facts that previously required a second GraphQL call per PR.
- A second aliased query fetches statusCheckRollup contexts only for
the PRs that matched. Checks for merged/closed PRs are frozen and
cached by repo#number@headOid, so idle workspaces settle to the
discovery query alone.
- A failed batch backs off every target in it with one request spent;
healthy aliases are recovered from the body gh still prints on
stdout when a single alias errors (ForgeCommandError now carries
stdout for this).
- Targets whose origin slug/host is still resolving, and fork repos
whose PR lives in the parent, fall back to the legacy per-target
path for that cycle. Unowned lookups filter out other forks' PRs
that share a branch name.
Fixes#3587. Addresses the API budget in #2470.
* fix(server): reserve the shared GitHub polling budget
---------
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
* feat(app): find workspaces by PR/MR number in the command center
The command center matched workspaces on title, host, project, and
branch. Typing the change-request number you actually remember found
nothing, so you had to recall the branch name instead.
Workspace results now carry their pull/merge request number, and a
numeric query resolves it directly: 42, #42, !42, pr 42, and mr 42 all
reach the same workspace. Matching compares the parsed number exactly
rather than as a substring, so 42 no longer drags in PR 142 or a branch
that happens to contain those digits. Exact number hits sort above
ordinary text matches for the same query.
Nothing changes visually. The number is a search key only; result rows
render exactly as before.
The number is read from the pull request the daemon already sends.
selectPrHintFromStatus is the fallback only: it derives the number by
parsing the PR/MR URL, which yields nothing for a noncanonical or
absent URL even when the daemon sent a usable number.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(app): exercise change-request parsing through the matcher
parseChangeRequestQuery was exported only so the test could reach it,
which made an implementation helper part of the module surface and let
the parser cases pass without covering how the parser composes with
workspace matching.
Unexport it and drive the same spellings through matchWorkspaceQuery.
The rejection cases now assert what they actually protect: a query like
fix-42-retries must not take the number path, so a workspace whose PR is
42 does not match it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* fix(server): keep Git pressure from stalling daemon
Git process starts were refilled synchronously, so slow process spawning could monopolize the main loop long enough to trip connection liveness checks. Admit one process per event-loop turn and expose refresh/fetch provenance in runtime queue metrics.
* perf(server): avoid delaying isolated Git commands
Start the first command immediately when the scheduler is idle. Backlog admission and completion-driven refills still yield between spawns, preserving daemon liveness without adding a turn to normal sequential Git traffic.
* fix(server): keep Git admission outside fake clocks
Subprocess admission is event-loop ordering rather than simulated wall-clock behavior. Use the native Node immediate so service tests can fake debounce timers without deadlocking queued real Git processes.
* refactor(server): bind Git command provenance explicitly
* test(server): expect bound Git fetch runner
* feat(sdk): expose a session's own commands on the agent handle
`DaemonClient.listCommands` has always been able to ask a running session
which slash commands and skills it loaded, but `PaseoAgentHandle` never
surfaced it, so SDK and plugin consumers could only see what they could
find on disk. Provider built-ins — the ones bundled inside the agent
binary rather than installed into a scannable directory — were therefore
invisible to them.
Add `agent.commands()` as a thin delegation to the existing RPC. Every
provider that implements `listCommands` (claude, codex, opencode, acp,
omp, pi) answers it, so a consumer no longer needs per-provider
filesystem knowledge to enumerate what a session can run.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(sdk): expose the observed agent snapshot on the agent handle
The handle mirrored four snapshot fields (workspaceId, cwd, status, id)
and hid the rest behind current(). Add getters for capabilities,
availableModes, pendingPermissions, activeTurn, lastUsage, lastError,
features, runtimeInfo, and archivedAt so callers can read one field
without destructuring the whole snapshot and null-checking it.
Each reads the last observed snapshot and never fetches, matching the
existing getters. null means "not observed yet".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* docs(sdk): clarify nullable snapshot getters
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: marvin-ambrozio <marvin@gustavo.eng.br>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
* fix(app): restore rewound text atomically
The native composer could consume a replacement revision before the matching draft-store value arrived, leaving the mounted input blank until remount. Carry the replacement text with its revision so scheduling order cannot split the command from its payload.
* fix(app): isolate launch composer drafts
Give chat and terminal launch composers separate React identities so each uncontrolled editor lifetime remains owned by its draft mode.
Retained workspace panels collapse to zero width while Settings is visible. Treat that geometry as an absent surface so hidden measurements cannot overwrite the last valid composer height before reveal.
* Add steering support for ACP providers that indicate support
* fix(acp): keep steering inside the admitted turn
---------
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Plugin client bundles bypass Metro, so authored async callbacks reached Hermes without Metro's syntax lowering. Plugin connection inputs also selected bottom-sheet behavior from device form factor instead of their actual render scope.
* feat(plugins): add timeline item contributions
Keep canonical daemon history unchanged while plugins project and render custom timeline items in clients. Live matches refresh authoritative projected history before replacement so lifecycle deltas stay collapsed.
* fix(nix): update npm dependency hash
The plugin SDK lockfile change invalidated the fixed-output npm dependency derivation. Use the hash calculated by the macOS Nix build for the current lockfile.
Preserve unmodelled keys at the document, settings, nested settings, and migration levels when an older desktop build writes known settings.
Validate known fields independently and keep the public DesktopSettings value limited to fields this build understands.
The Changes view listed the same files in two different orders. The
tree rail put directories before files at each level of the path.
The scrolling diff compared the full path byte by byte. A root file
such as .gitlab-ci.yml therefore opened the diff, but sat below every
directory in the tree. Scrolling the diff did not walk the tree from
top to bottom.
Two sorts caused this. sortTree in git/diff-tree.ts sorted the tree.
compareCheckoutDiffPaths in git/diff-order.ts sorted the flat list at
ingest.
The tree is now the only ordering authority.
orderCheckoutDiffFiles builds the tree and reads the file sequence
back out of it. compareCheckoutDiffPaths is deleted, so the two
surfaces can no longer disagree.
The daemon keeps its own whole-path sort, so daemons that are not
updated also get this fix. Commit diffs keep the order that git
gives them, because that panel shows no tree rail.
Make every trusted Git repository directly distributable without introducing a registry or package manager. Managed checkouts retain their resolved commit, track branches explicitly, and stage validation before activation so failed updates restore the running version.
* fix(app): paint cached conversations before reconnect
Cold startup restored every saved host before publishing any session, so a valid cached chat was blocked behind unrelated directory validation and network-only readiness. Directory and timeline owners now resolve demand through typed storage and authoritative reconciliation while consumers remain source-agnostic.
* fix(app): stabilize demand-driven cache restore
Create host projections before network availability, route cached workspace and timeline preparation through their owners, and coalesce directory reconciliation per online connection. Update browser cache fixtures for per-agent timeline rows.
* fix(app): resume cached timeline through owner
* fix(app): surface timeline owner load errors
* fix(app): route workspace actions semantically
Separate Explorer visibility from view navigation, and give composer changes and pull request opens semantic routing owners. Unify diff destinations, keep compact PR navigation in Explorer, and derive maximize availability from visible workspace panes.
* test(app): cover composer changes routing
Update the browser journey for the Explorer-first composer action, then assert the second press follows the shared diff destination.
* chore(server): upgrade Claude Agent SDK to 0.3.246
Bumps @anthropic-ai/claude-agent-sdk from 0.3.220 to 0.3.246, keeping the
pinned exact version so fresh installs stay reproducible.
The SDK's peer requirements are unchanged (@anthropic-ai/sdk >=0.93.0,
@modelcontextprotocol/sdk ^1.29.0, zod ^4.0.0) and already satisfied, so
nothing else in the tree moves.
Nothing Paseo imports was removed or narrowed. The type surface Paseo
touches is additive; the removed ExitReason member
'bypass_permissions_disabled' and the OnElicitation/OnUserDialog signature
changes are unused here.
* chore(nix): refresh npm deps hash for the SDK bump
---------
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Provider callbacks can be canceled without a user response. Publish a terminal resolution for that path so every surfaced request has a matching lifecycle event, while preserving the existing interruption cleanup.
Automatic readiness polls reapplied the stable rollout gate and cleared the same update that a manual check had already admitted. Keep same-version admission monotonic while still clearing withdrawn releases and rejecting newer releases that have not reached the user.
The Explorer pane rewrite routed semantic Changes and pull-request actions through ordinary workspace tabs on compact layouts. Centralize those actions so mobile and native dock layouts select the Explorer surface while desktop keeps its placement preferences.
Serialize pending batches at write execution time so retries cannot overwrite newer rows or resurrect removed hosts. Reject oversized hosts before storage writes and preserve unchanged directory identities so routine reconciliation stays row-grained.
Persist only changed replica entities so steady-state writes scale with the update rather than the complete cached world. Restore remains an all-or-nothing validated boundary, with whole-host byte-budget eviction.
Ship the platform storage engines behind a dark interface so the cache migration can wire them separately. Both engines share a contract, including atomic writes and destructive schema-version resets.
Add an observational live measurement harness for the row-store migration baseline. Trust already-shaped cache values on persist while retaining full schema validation at the restore boundary.
* add f-droid metadata support
* share version code calculation with website
* preserve blockquoted sections in changelogs
* refactor(android): simplify stable F-Droid metadata
Generate store changelogs only for stable releases and keep native version-code ownership in the existing release module.
* test(android): run F-Droid changelog suite in CI
---------
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
* feat(protocol): add forge check presentation metadata
Expose optional rawStatus, isManual, and requiresAction fields while keeping older check payloads backward-compatible.
* fix(gitea): model Actions jobs as tasks
Gitea's /actions/tasks endpoint returns individual jobs rather than workflow runs. Preserve sibling tasks from the latest execution, avoid collapsing same-name jobs, and enrich blocked tasks with run-level approval metadata.
* feat(app): distinguish forge check presentation states
* fix(gitlab): preserve pipeline job semantics
* refactor(app): centralize forge check presentation policy
Use one canonical category order, count shape, visual policy, and translated copy mapping across sidebar, hover-card, PR-pane, and GitLab pipeline surfaces.
* refactor: consolidate forge check handling
* feat(forge): auto-attach pasted forge references
* docs(compat): correct forge rollout metadata
* refactor(forge): model check nuances as traits
* fix(forge): harden checks from branch review
Structural Gitea Actions shadow dedup by run id (verified upstream),
rerun grouping without workflow_id, shared trait/status constants and
Gitea state mapping in the protocol package, all cloud host aliases in
pasted-link matching, logged glab job-detail failures, concurrent
approvals/pipeline fetch, checks aggregate from the same pipeline as
the job list, and removal of the unread check-level rawStatus. The
released pipeline-level rawStatus is staged for removal under
COMPAT(pipelineRawStatus).
* fix(gitea): detect fork-PR Actions approval hold as action_required
gitea.com represents a fork PR awaiting Actions approval as a run in
status "waiting" with no tasks and pending shadow commit statuses; it
does not expose need_approval. The previous mapping only set
action_required from a task with blocked+need_approval, so the gate
never surfaced (0 tasks -> early return; need_approval absent). Detect
the hold from /actions/runs: a waiting/blocked run whose PR is
cross-repo (head.repo.id != base.repo.id), or one that does expose
need_approval (self-hosted). Tag the run's pending shadow statuses
action_required. Runs are only fetched when a pending shadow has no
covering task, keeping the common path at two requests. Verified live
against gitea.com fork PR (run 715170, waiting, cross-repo).
* fix(gitea): resolve cross-fork PRs by head sha
The current-PR matcher gated candidates on the checkout owner
(giteaHeadOwner === expectedHeadOwner), which excludes every fork PR:
its head carries the fork owner while the checkout is the base repo, so
a fork PR - the only shape that can be approval-held on Gitea - never
resolved and its checks never showed in-app. GitLab has no such gate
(it resolves fork MRs from the target project by source branch). Accept
a recent PR whose head sha equals the workspace head sha regardless of
owner; the sha is an unambiguous identity, so the anti-collision owner
gate still applies when no sha match is available. Verified live against
gitea.com fork PR #6 (resolves + renders action_required).
* fix: resolve forge rebase drift
* fix: type forge auto-attach boundary
* fix(i18n): add Korean check status copy
---------
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
* Stream assistant text at a steady rate instead of in arrival lumps
Assistant text arrived in lumps and was painted that way. The daemon
coalesces one message per 60ms window, and that window carries however
many characters the model produced in it, which swings by an order of
magnitude within a turn. Painting each delta as it landed made the size
of those lumps visible.
Three changes:
- The agent stream coalescer is now leading + trailing. The first delta
after an idle window flushes synchronously instead of waiting a full
window, matching the invariant TerminalOutputCoalescer already
documents. The window stays at 60ms; shrinking it would raise message
rate on a loop that already contends with terminal frames and relay
encryption, without making the stream smoother.
- The client reducer queue commits on a frame instead of an arbitrary
48ms timer, with the timer kept as a ceiling so a hidden tab still
advances.
- Assistant and reasoning text are revealed at a rate derived from the
backlog rather than from arrival timing. Arrival changes the target,
not the rate. The store keeps the full text and only the rendered
slice is paced, so copy, selection, the chat outline, and scroll
geometry all stay consistent with what is on screen.
The leading flush means a burst's first chunk lands as its own timeline
row. The projection already merges contiguous same-turn rows and clients
read the projected timeline, so history is unchanged; the server tests
that assert on raw rows were updated for the extra row.
Measured on Expo web against a local dev daemon. Mock bursty-stream
provider, 6s samples, paint-on-arrival vs paced: frames that advanced
the text 55 -> 244, chars-per-frame CV 2.66 -> 1.44, gap p50 100ms ->
17ms. Real Claude Haiku agent over 52s: 84% of frames advance the text,
CV 1.29, gap p50 and p95 both 17ms (one frame at 60Hz).
Adds a bursty-stream mock model with seeded burst sizes, a gated browser
perf spec, and docs/agent-stream-performance.md.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Address review: safe reveal boundaries and a testable seam
Two findings from the PR review.
Unicode boundaries. Slicing the revealed text at an arbitrary index could
strand a lone surrogate, which paints as a replacement glyph, or split an
emoji ZWJ sequence so a family briefly rendered as its members. Both were
visible for a frame or two mid-stream — the exact flicker the paced reveal
exists to remove. The rendered cut is now pulled back off surrogate pairs,
combining marks, variation selectors, skin tone modifiers, and ZWJ joins.
The reveal counter stays monotonic and only the painted boundary moves, so
a long cluster cannot stall the reveal.
Test placement. The hook test used JSDOM, @testing-library renderHook, and
monkey-patched requestAnimationFrame, all of which docs/testing.md rules
out; it prescribes fixing the missing seam instead. The reveal policy is
now a pure state machine in text-reveal.ts (begin/retarget/advance/
complete/visible) covered by plain unit tests with no environment, and the
hook is reduced to the frame-clock binding. Rendered behavior is left to
the browser spec, which no longer carries frame sampling and statistics in
its body — those moved to e2e/support/helpers/stream-smoothness.ts.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Measure total painted text, not the tail message
The smoothness sampler read the length of the last assistant-message
element. A real turn emits many assistant messages, so that element keeps
changing identity and its length is not monotonic — handovers read as
resets, and a stream that was plainly moving on screen reported almost no
growth at all. Sampling the total across every assistant message fixes it.
This invalidated the numbers previously recorded for the real-agent run,
so the doc table is replaced with a fresh A/B taken with the corrected
sampler against a Claude Haiku agent: frames that advanced the text 6% ->
87%, chars-per-frame CV 4.11 -> 1.86, gap p50 317ms -> 17ms, p95 383ms ->
17ms. The paced column also reported zero Unicode defects across a stream
containing emoji, which exercises the reveal boundary clamp end to end.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* perf(agent-stream): harden paced text reveal
Use standards-compliant grapheme segmentation when available and preserve full arrivals on older runtimes. Cap reveal commits at 60Hz and measure the real browser rendering path so high-refresh displays stay smooth without rendering at hardware frame rate.
* Hold back trailing fragments split by arrival boundaries
The reveal boundary is not the only place a grapheme gets cut. The
daemon's coalescing window ends a delta wherever the window closes, so a
caught-up reveal can be handed half a cluster and paint it. Measured on a
mixed-script Haiku stream: five frames in ~350 ending in a single regional
indicator, i.e. half a flag on screen for a frame.
While the turn is streaming, a trailing fragment that cannot stand alone —
a dangling high surrogate, a trailing joiner, an odd regional indicator —
now waits for the rest of its cluster. Once the turn ends everything
received is painted, because nothing more is coming. Complements the
grapheme-boundary clamp, which handles the reveal cut; this handles the
arrival cut.
The same stream now measures one such frame in ~400 rather than five. The
remainder is a flush boundary that coincides with the end of an assistant
message, putting the two halves in different messages, which the client
cannot rejoin — that one belongs in the coalescer.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
* fix(rewind): refresh canonical timeline after rewind
A rewind replaces the daemon timeline and epoch, but replaying the rebuilt history made long conversations visibly reload from the beginning. Capable clients now refresh the existing bounded tail path after a replacement signal while legacy clients retain replay compatibility.
* test(rewind): cover coherent timeline replacement
Hold rewind completion in the browser regression so it can verify the existing pending state and detect any reconstructed timeline rows before the normal tail refresh is allowed to finish.
The first-class Explorer reused the old Side panel persistence identity, so upgrades hydrated ordinary persisted tabs into the dock. Move those tabs into an ordinary side pane during the versioned migration while preserving main layouts, and keep the persisted key spelling stable at the storage boundary.
The Command Center tested the whole trimmed query as one substring
against one concatenated haystack, so a query only matched when its
words appeared consecutively, in that order, with that spacing. Typing
`shortcuts` found "Keyboard shortcuts"; typing `short key` found
nothing. Survivors were then ordered by a fixed group rank, so the
best match was rarely first.
Route both through `scoreTextFields`, the matcher the combobox,
provider selection, directory suggestions and the daemon's history
search already share. It splits on whitespace, scores each token
against each field on its own, and requires every token to land
somewhere. `short key`, `key short`, `workspace new` and `project add`
all find their row now.
The score is the filter: `scoreSearchFields` returns null when a token
finds no home, so filtering and ranking are one pass rather than a
boolean predicate that throws the scan away. Workspaces and agents are
scored too, not just filtered, with each row's own ordering kept as the
tiebreak. Fields are split into visible and hidden, so a match on an
agent's `cwd` — which is never rendered — cannot outrank a match on its
title.
Subsequence matching is deliberately off here, unlike the composer's
slash-command list where it makes `/pasbab` find `/paseo-babysit`. It
widens far enough that three-letter typos hit several rows each, and
this list preselects its first row, so a query that should find nothing
would put an unrelated action under Enter. `MatchOptions.subsequence`
defaults to on, leaving every other caller unchanged. The tier has to
be suppressed during scoring rather than filtered afterwards, because
a two-token total of tier 5 is either 0+5 or 4+1.
Sections carry a band so the ones that reorder by relevance are
compared before the pinned ones, which keeps the comparator total: file
rows are matched by the daemon and carry no client-side score, so they
cannot be relevance-compared against rows that do.
Contiguous per-token matching is a strict widening of the old test.
Tokens hold no whitespace and the old haystack joined fields with a
space, so any token found there lay entirely within one field and is
still found. Verified across a query set at zero rows lost, and pinned
by a test.
* fix(server): keep current PR visible without unsafe archival
PR discovery and merge-triggered archival answer different questions. Follow the checked-out branch for status, while requiring the daemon to observe the same PR move from open to merged before archiving a workspace.
* test(server): align managed worktree PR lookup expectation
Managed metadata without a matching PR target must not hide the unambiguous PR identity from the current branch tracking configuration.
* fix(server): trust provider connection lifecycle events
Readiness now waits for the upstream connection event, and later connection events directly trigger the existing gap repair. This removes the parallel synthetic reconnection contract while preserving transport ownership and prompt gating.
* test(server): model OpenCode global event envelopes
* feat(hub): guide starter setup (#3651)
* feat(hub): guide setup with provider IDs
* fix(hub): preserve login setup state
* feat(hub): select starter agent runtime
Use the connected daemon's provider snapshot so generated Hub bundles only
contain complete provider, model, and mode combinations.
* fix(hub): stage starter runtime choices
Keep providers with valid modes selectable when the daemon has no default mode, so users explicitly choose the complete runtime.
* fix(hub): scope runtime choices to workspace
Provider capabilities can vary by repository, so request the snapshot for the bundle directory that guided setup will deploy.
* fix(server): stop short start gates from undercutting provider startup budgets (#3621)
* fix(server): stop short start gates from undercutting provider startup budgets
Agent creation still failed on contended cold starts after #3578, because two
shorter gates on the same path fire before the event-stream readiness budget can
apply. A traced creation reached session.create twelve seconds in, past the 10s
session.create gate, and the agent was then killed by a bare "timeout" at fifteen
seconds from AGENT_RUN_START_TIMEOUT_MS while the SSE connect was still running.
A run-start bound of 15s wraps a path that is deliberately allowed 30s, so the
outer gate always wins and the inner budget is unreachable.
Give session.create the shared OPENCODE_SERVER_STARTUP_TIMEOUT_MS budget, since
creating the first session for a directory is part of OpenCode coming up, and
derive its error message from the constant instead of hardcoding "10s". Raise
AGENT_RUN_START_TIMEOUT_MS to 60s with a comment recording that it must stay
above the slowest provider startup budget; it stays a literal because that module
is provider-agnostic.
Related to #3571.
* test(server): drive the run start budget through a real AgentManager
The run start boundary tests replaced AgentManager.waitForAgentRunStart with a
bare timer, so they never exercised the production run-state transition or the
agent_state subscription the real wait depends on. That path could regress while
the tests stayed green.
Build the scenario on a real AgentManager with a provider session that holds its
turn start open for a configurable span, so pendingRun.started, the lifecycle
change to "running", and the agent_state notification are what the wait actually
observes. Budgets stay independent literals and the settled-before/after
assertions are unchanged.
* fix(server): describe agent run start timeouts
---------
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
* fix(relay): validate handshake keys (#3669)
* fix(files): keep large source previews responsive (#3665)
Large read-only files bypassed the editor's virtualization and eagerly mounted every highlighted token, which could stall the renderer for seconds and consume gigabytes of memory. Route source previews through bounded platform renderers and reject reads above their display budgets before accumulation.
* fix(hub): make guided setup choices actionable (#3677)
Show a concise login success state, scope starter choices to available Hub connections, wait for daemon capabilities, and avoid partial starter bundles when setup cannot proceed.
* Keep released agent SDK versions reproducible (#3678)
Pin the validated provider and protocol SDK dependency pair so fresh installs cannot silently float after a Paseo release. Refresh the Nix npm dependency hash for the resulting lockfile.
* feat(app): refine agent profile creation (#3680)
Keep profiles as the richer primitive while restoring the familiar star cue and clearer guidance for agent selection. Account for borders in shared field geometry so controls preserve their declared heights across form consumers.
* fix(app): restore native composer auto-growth (#3681)
Native multiline inputs were always scrollable, which kept iOS content-size measurement pinned to one line. Keep native sizing intrinsic below the cap and enable internal scrolling only after the composer reaches it.
* docs(hub): document zero-friction onboarding (#3657)
Lead with the browser-to-CLI onboarding journey, document the generated starter bundle, and align guided setup with available Hub connections and daemon-reported runtime defaults.
* Surface workspace changes from agent panes (#3682)
* feat(app): refine workspace pane actions
Prioritize Changes when available, keep Files useful before a selection, and reserve desktop diff clicks for selection so comments require the explicit affordance. Move the Changes shortcut to Shift+G to avoid consuming a likely copy variant.
* feat(app): improve access to workspace changes
* Expand workspace panel actions and keyboard navigation (#3685)
* fix(app): keep file navigation in place
Retargeting a File tab allocated a new tab identity, causing the tab track to unmount and remount during navigation. Preserve the identity for File-to-File changes while retaining new instances for cross-kind replacements.
* feat(app): expand workspace panel actions
Keep the Command Center aligned with workspace tab and pane primitives. Add explicit panel placement, focused open behavior, keyboard navigation for empty panes, and a Changes pill that toggles the visible Side panel without closing its tab.
* Restore compact and stable chat timelines (#3716)
Restore compact message and footer spacing, reserve independent composer-pill clearance for transcript content and floating controls, and keep visible web tool-call expansion from shifting the reading position.
* perf(app): defer replica persistence until user inactivity
Whole-replica capture and serialization blocked the JS thread during navigation and streaming. Defer that work until the user has been inactive while allowing long-running streams to persist without becoming the idle signal.
* Give every pane a browser-style New tab (#3715)
* feat(app): replace empty panes with New tabs
Give every retained pane an independently identified New tab, replace it in place when a destination is chosen, and preserve split close and move invariants. Keep existing workspace startup and compact explorer routing explicit, with focused unit and browser regression coverage.
* test(app): count content tabs in subagent integration
* fix(app): preserve workspace shortcut ownership
* fix(app): keep compact model options scrollable
The compact model sheet had no sheet-aware scroll owner when profiles filled its viewport, leaving the controls below them unreachable. Keep the root provider view inside Gorhom's scroll container so native sheet gestures work on both platforms.
* fix(app): order new tab launcher actions by workflow
* fix(app): show the New agent shortcut
The command center resolved New agent through the New tab binding, so it advertised Cmd+T despite creating an agent. Resolve the target-agent binding instead and cover the mapping.
* fix(app): prioritize side panel launcher actions
Lead with supporting workspace tools when the launcher is in the Side panel, while ordinary panes continue to lead with agent creation.
* fix(app): align terminal profiles header
* docs: add 0.5.0-beta.5 changelog
* test(app): align new tab launcher expectations
* chore(release): cut 0.5.0-beta.5
* fix: update lockfile signatures and Nix hash [skip ci]
* fix(app): make compact git actions menu-only
Keep compact toolbars narrow and require users to choose potentially consequential Git and workspace actions from the menu.
* fix(app): keep pinned project actions reachable (#3722)
* fix(app): keep pinned project actions reachable
Preserve an empty project shell after its final workspace is hoisted into the pinned section so project-scoped workspace creation remains available.
Co-authored-by: Agent Op <agentop718@gmail.com>
* test(app): verify pinned project workspace context
---------
Co-authored-by: Agent Op <agentop718@gmail.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
* fix(app): stop a string SVG transform from crashing the app (#3711)
* fix(app): stop a string SVG transform from crashing the app
A root <Svg> is a real React Native view, so react-native-svg rewrites a
*string* `transform` into RN style syntax in `extractTransformSvgView`,
using a PEG parser whose grammar only accepts SVG transform functions.
Upstream lets that parser's SyntaxError escape, so any string it does not
understand takes the whole app down during render:
SyntaxError: Expected transform functions but "n" found.
"found": "n", "location": { "start": { "offset": 0 } }
`none` reproduces that message character for character; so does any other
CSS value (`rotate(-90deg)`, `translate(10px, 0)`). Every sibling
extractor in that file already catches the same error and degrades, so
the asymmetry is the bug: a string that is merely ignored on a <Path>
crashes on an <Svg>.
We feed that path untrusted input. The daemon scans a project for
favicon.svg / icon.svg / logo.svg (packages/server/src/utils/project-icon.ts)
and ships it to the client, where ProjectIconImage hands it to SvgCss.
Running the library's own XML parser over `<svg style="transform:none">`
yields exactly `transform: "none"` on the root, which is why the crash
starts once a host connects and the project icons render, with no
terminal involved.
Note this is *not* reachable from an RN-style array transform: the
extractor returns arrays untouched and only parses strings, so
`style={{ transform: [{ rotate: "-90deg" }] }}` (the context window meter
and the checks ring) was never a factor.
Patch the three builds a consumer can resolve - Metro takes the package's
`react-native` field (src/*.ts), Node and Vitest take main/module - and
register react-native-svg with the postinstall patch runner so the patch
actually applies. The new test pins all three.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EDwfsMWoSFfPm2cFXe5noQ
* test(app): cover parsed root SVG transforms
Exercise the dependency patch with root props produced by the library's XML and CSS parsers, including embedded style rules that can arrive through project icons. Keep dependency-patch documentation focused on the reusable install and resolution constraints.
---------
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
* fix(pairing): warn that pairing links grant daemon access (#3734)
A pairing offer remains valid across daemon restarts, so remove the incorrect rotation guidance and warn users where the durable credential is displayed.
* fix(app): preserve timeline retry backoff (#3736)
Equivalent visibility publications were acting as implicit retry commands, so unrelated tab and layout changes could restart failed timeline work. Keep visibility identity stable and reserve immediate retries for explicit user action.
* Open the tab chooser beside the pointer (#3735)
* feat(app): open the tab chooser beside the pointer
Keep keyboard-created and empty-pane New tabs as persistent launcher surfaces, while giving pointer users an anchored chooser at the pane-local plus button. Both presentations resolve the same launch catalogue so future ordering and customization cannot drift.
* test(app): follow the anchored tab chooser
The browser helpers still searched inside the pane for persistent-launcher controls after clicking the portalled menu trigger. Exercise the menu through its own roles and stable catalogue IDs.
* fix(app): restore composer height lifecycle (#3740)
Native Fabric can omit the initial content-size callback and retain stale size after deletion. Keep the common composer surface free of platform branches while native and web adapters own their measurement mechanisms.
* fix(server): stop quota fetcher from writing provider credentials (#3442)
* fix(server): recover already-settled interrupts (#3742)
* fix(server): recover already-settled interrupts
Provider state can drift from Paseo’s tracked run. Treat an authoritative already-idle interrupt as settled and retire the captured run token so retrying can start new work without weakening ambiguous-cancellation protection.
* fix(codex): fence interruption during turn startup
Reserve foreground-start ownership before asynchronous provider setup. An interrupt can now cancel preflight before turn/start or wait for an issued start to become interruptible, preventing acknowledged cancellation from leaving unmanaged work.
* docs(changelog): prepare 0.5.0 stable release
* chore(release): cut 0.5.0
* fix: update lockfile signatures and Nix hash [skip ci]
* fix(app): stabilize native composer resizing
JavaScript height state competed with native intrinsic layout across draft and agent ownership transitions. Let native inputs own sizing and scrolling while preserving DOM measurement on web.
* docs(changelog): prepare 0.5.1 stable release
* chore(release): cut 0.5.1
* fix: update lockfile signatures and Nix hash [skip ci]
* perf(app): move replica cache validation to restore
Add an observational live measurement harness for the row-store migration baseline. Trust already-shaped cache values on persist while retaining full schema validation at the restore boundary.
* feat(app): add replica cache row store
Ship the platform storage engines behind a dark interface so the cache migration can wire them separately. Both engines share a contract, including atomic writes and destructive schema-version resets.
* feat(app): switch replica cache to row storage
Persist only changed replica entities so steady-state writes scale with the update rather than the complete cached world. Restore remains an all-or-nothing validated boundary, with whole-host byte-budget eviction.
* fix(app): harden replica row persistence
Serialize pending batches at write execution time so retries cannot overwrite newer rows or resurrect removed hosts. Reject oversized hosts before storage writes and preserve unchanged directory identities so routine reconciliation stays row-grained.
---------
Co-authored-by: BrianAguilarWasco <146237330+BrianAguilarWasco@users.noreply.github.com>
Co-authored-by: paseo-ai[bot] <266920839+paseo-ai[bot]@users.noreply.github.com>
Co-authored-by: Yuri Gui <yugui923@users.noreply.github.com>
Co-authored-by: Agent Op <agentop718@gmail.com>
Co-authored-by: Omer Cohen <639682+omercnet@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Dan Berindei <dan.berindei@gmail.com>
The readiness wait expired at the same boundary as the producer watchdog, so a reconnect already initiated by the event consumer could never benefit the pending turn. Keep dispatch gated, allow that retry to land, and preserve the actual SDK failure details for logs and user-visible reports.
* test(e2e): boot the Playwright harness on Windows
The harness could not start on Windows. Metro was launched with
spawn("npx", ...) and the isolated daemon resolved its runner with
execSync("which tsx") — npx is a .cmd shim that Node's bare spawn
cannot launch, and `which` does not exist there.
Route both through process.execPath instead of the node_modules/.bin
shims. Node refuses to spawn .cmd or .bat without shell: true, and
shell: true is the wrong escape hatch: it concatenates argv unescaped
(DEP0190), splits paths containing spaces, and sends kill signals to
cmd.exe rather than the real child. Spawning Node directly has none of
those properties and is identical on POSIX, so no platform branch is
needed. The same reasoning fixes the published-server npm install,
which passed "npm.cmd" to execFileSync and had been failing with
EINVAL on Windows since Node's CVE-2024-27980 mitigation landed.
Teardown now kills the process tree. A Windows signal reaches only the
direct child, so Metro's and the supervisor's forked workers kept the
listening port and the next run failed to bind.
The daemon startup deadline goes from 20s to 90s. A cold tsx start was
measured at 25s on Windows and exceeded 60s under load; the loop still
fails immediately on a crashed child, so the ceiling only costs time
that a genuinely slow start needs.
The fake editor and gh binaries the harness puts on PATH gain .cmd
wrappers, because Windows resolves PATH entries by PATHEXT and cannot
execute an extension-less shebang file. The wrappers point at the same
payload file so the recorded command name stays "cursor", "code", or
"gh" on every platform.
The asdf-backed local Elixir relay stays POSIX-only and now says so
before spawning.
* test(ci): follow shared e2e daemon launcher
---------
Co-authored-by: ABorakati <ABorakati@users.noreply.github.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
* fix(settings): preserve settings from newer builds
Strict blob validation treated a single newer setting as corruption and then rewrote defaults. Read settings field by field and retain unrecognized data when materializing or migrating known preferences.
* refactor(settings): unify discarded-value warnings
Emit settings warnings where each stored value is parsed so validation cannot drift from diagnostics.
* refactor(settings): validate persisted settings declaratively
Use one loose Zod schema with field defaults so mixed-version settings remain loadable without parallel validation paths.
* fix(settings): preserve explicit services preferences
Keep compatibility fallbacks from overriding explicit settings and prevent read metadata from reaching migration writes.
* refactor(settings): derive migration persistence types
Let the schema prove normalized settings coverage and carry its persisted shape through migrations.
JavaScript height state competed with native intrinsic layout across draft and agent ownership transitions. Let native inputs own sizing and scrolling while preserving DOM measurement on web.
* fix(server): recover already-settled interrupts
Provider state can drift from Paseo’s tracked run. Treat an authoritative already-idle interrupt as settled and retire the captured run token so retrying can start new work without weakening ambiguous-cancellation protection.
* fix(codex): fence interruption during turn startup
Reserve foreground-start ownership before asynchronous provider setup. An interrupt can now cancel preflight before turn/start or wait for an issued start to become interruptible, preventing acknowledged cancellation from leaving unmanaged work.
Native Fabric can omit the initial content-size callback and retain stale size after deletion. Keep the common composer surface free of platform branches while native and web adapters own their measurement mechanisms.
* feat(app): open the tab chooser beside the pointer
Keep keyboard-created and empty-pane New tabs as persistent launcher surfaces, while giving pointer users an anchored chooser at the pane-local plus button. Both presentations resolve the same launch catalogue so future ordering and customization cannot drift.
* test(app): follow the anchored tab chooser
The browser helpers still searched inside the pane for persistent-launcher controls after clicking the portalled menu trigger. Exercise the menu through its own roles and stable catalogue IDs.
Equivalent visibility publications were acting as implicit retry commands, so unrelated tab and layout changes could restart failed timeline work. Keep visibility identity stable and reserve immediate retries for explicit user action.
A pairing offer remains valid across daemon restarts, so remove the incorrect rotation guidance and warn users where the durable credential is displayed.
* fix(app): stop a string SVG transform from crashing the app
A root <Svg> is a real React Native view, so react-native-svg rewrites a
*string* `transform` into RN style syntax in `extractTransformSvgView`,
using a PEG parser whose grammar only accepts SVG transform functions.
Upstream lets that parser's SyntaxError escape, so any string it does not
understand takes the whole app down during render:
SyntaxError: Expected transform functions but "n" found.
"found": "n", "location": { "start": { "offset": 0 } }
`none` reproduces that message character for character; so does any other
CSS value (`rotate(-90deg)`, `translate(10px, 0)`). Every sibling
extractor in that file already catches the same error and degrades, so
the asymmetry is the bug: a string that is merely ignored on a <Path>
crashes on an <Svg>.
We feed that path untrusted input. The daemon scans a project for
favicon.svg / icon.svg / logo.svg (packages/server/src/utils/project-icon.ts)
and ships it to the client, where ProjectIconImage hands it to SvgCss.
Running the library's own XML parser over `<svg style="transform:none">`
yields exactly `transform: "none"` on the root, which is why the crash
starts once a host connects and the project icons render, with no
terminal involved.
Note this is *not* reachable from an RN-style array transform: the
extractor returns arrays untouched and only parses strings, so
`style={{ transform: [{ rotate: "-90deg" }] }}` (the context window meter
and the checks ring) was never a factor.
Patch the three builds a consumer can resolve - Metro takes the package's
`react-native` field (src/*.ts), Node and Vitest take main/module - and
register react-native-svg with the postinstall patch runner so the patch
actually applies. The new test pins all three.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EDwfsMWoSFfPm2cFXe5noQ
* test(app): cover parsed root SVG transforms
Exercise the dependency patch with root props produced by the library's XML and CSS parsers, including embedded style rules that can arrive through project icons. Keep dependency-patch documentation focused on the reusable install and resolution constraints.
---------
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
* fix(app): keep pinned project actions reachable
Preserve an empty project shell after its final workspace is hoisted into the pinned section so project-scoped workspace creation remains available.
Co-authored-by: Agent Op <agentop718@gmail.com>
* test(app): verify pinned project workspace context
---------
Co-authored-by: Agent Op <agentop718@gmail.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
The command center resolved New agent through the New tab binding, so it advertised Cmd+T despite creating an agent. Resolve the target-agent binding instead and cover the mapping.
The compact model sheet had no sheet-aware scroll owner when profiles filled its viewport, leaving the controls below them unreachable. Keep the root provider view inside Gorhom's scroll container so native sheet gestures work on both platforms.
* feat(app): replace empty panes with New tabs
Give every retained pane an independently identified New tab, replace it in place when a destination is chosen, and preserve split close and move invariants. Keep existing workspace startup and compact explorer routing explicit, with focused unit and browser regression coverage.
* test(app): count content tabs in subagent integration
* fix(app): preserve workspace shortcut ownership
Whole-replica capture and serialization blocked the JS thread during navigation and streaming. Defer that work until the user has been inactive while allowing long-running streams to persist without becoming the idle signal.
Restore compact message and footer spacing, reserve independent composer-pill clearance for transcript content and floating controls, and keep visible web tool-call expansion from shifting the reading position.
* fix(app): keep file navigation in place
Retargeting a File tab allocated a new tab identity, causing the tab track to unmount and remount during navigation. Preserve the identity for File-to-File changes while retaining new instances for cross-kind replacements.
* feat(app): expand workspace panel actions
Keep the Command Center aligned with workspace tab and pane primitives. Add explicit panel placement, focused open behavior, keyboard navigation for empty panes, and a Changes pill that toggles the visible Side panel without closing its tab.
* add f-droid metadata support
* share version code calculation with website
* preserve blockquoted sections in changelogs
* refactor(android): simplify stable F-Droid metadata
Generate store changelogs only for stable releases and keep native version-code ownership in the existing release module.
* test(android): run F-Droid changelog suite in CI
---------
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
* feat(protocol): add forge check presentation metadata
Expose optional rawStatus, isManual, and requiresAction fields while keeping older check payloads backward-compatible.
* fix(gitea): model Actions jobs as tasks
Gitea's /actions/tasks endpoint returns individual jobs rather than workflow runs. Preserve sibling tasks from the latest execution, avoid collapsing same-name jobs, and enrich blocked tasks with run-level approval metadata.
* feat(app): distinguish forge check presentation states
* fix(gitlab): preserve pipeline job semantics
* refactor(app): centralize forge check presentation policy
Use one canonical category order, count shape, visual policy, and translated copy mapping across sidebar, hover-card, PR-pane, and GitLab pipeline surfaces.
* refactor: consolidate forge check handling
* feat(forge): auto-attach pasted forge references
* docs(compat): correct forge rollout metadata
* refactor(forge): model check nuances as traits
* fix(forge): harden checks from branch review
Structural Gitea Actions shadow dedup by run id (verified upstream),
rerun grouping without workflow_id, shared trait/status constants and
Gitea state mapping in the protocol package, all cloud host aliases in
pasted-link matching, logged glab job-detail failures, concurrent
approvals/pipeline fetch, checks aggregate from the same pipeline as
the job list, and removal of the unread check-level rawStatus. The
released pipeline-level rawStatus is staged for removal under
COMPAT(pipelineRawStatus).
* fix(gitea): detect fork-PR Actions approval hold as action_required
gitea.com represents a fork PR awaiting Actions approval as a run in
status "waiting" with no tasks and pending shadow commit statuses; it
does not expose need_approval. The previous mapping only set
action_required from a task with blocked+need_approval, so the gate
never surfaced (0 tasks -> early return; need_approval absent). Detect
the hold from /actions/runs: a waiting/blocked run whose PR is
cross-repo (head.repo.id != base.repo.id), or one that does expose
need_approval (self-hosted). Tag the run's pending shadow statuses
action_required. Runs are only fetched when a pending shadow has no
covering task, keeping the common path at two requests. Verified live
against gitea.com fork PR (run 715170, waiting, cross-repo).
* fix(gitea): resolve cross-fork PRs by head sha
The current-PR matcher gated candidates on the checkout owner
(giteaHeadOwner === expectedHeadOwner), which excludes every fork PR:
its head carries the fork owner while the checkout is the base repo, so
a fork PR - the only shape that can be approval-held on Gitea - never
resolved and its checks never showed in-app. GitLab has no such gate
(it resolves fork MRs from the target project by source branch). Accept
a recent PR whose head sha equals the workspace head sha regardless of
owner; the sha is an unambiguous identity, so the anti-collision owner
gate still applies when no sha match is available. Verified live against
gitea.com fork PR #6 (resolves + renders action_required).
* fix: resolve forge rebase drift
* fix: type forge auto-attach boundary
* fix(i18n): add Korean check status copy
---------
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
* Stream assistant text at a steady rate instead of in arrival lumps
Assistant text arrived in lumps and was painted that way. The daemon
coalesces one message per 60ms window, and that window carries however
many characters the model produced in it, which swings by an order of
magnitude within a turn. Painting each delta as it landed made the size
of those lumps visible.
Three changes:
- The agent stream coalescer is now leading + trailing. The first delta
after an idle window flushes synchronously instead of waiting a full
window, matching the invariant TerminalOutputCoalescer already
documents. The window stays at 60ms; shrinking it would raise message
rate on a loop that already contends with terminal frames and relay
encryption, without making the stream smoother.
- The client reducer queue commits on a frame instead of an arbitrary
48ms timer, with the timer kept as a ceiling so a hidden tab still
advances.
- Assistant and reasoning text are revealed at a rate derived from the
backlog rather than from arrival timing. Arrival changes the target,
not the rate. The store keeps the full text and only the rendered
slice is paced, so copy, selection, the chat outline, and scroll
geometry all stay consistent with what is on screen.
The leading flush means a burst's first chunk lands as its own timeline
row. The projection already merges contiguous same-turn rows and clients
read the projected timeline, so history is unchanged; the server tests
that assert on raw rows were updated for the extra row.
Measured on Expo web against a local dev daemon. Mock bursty-stream
provider, 6s samples, paint-on-arrival vs paced: frames that advanced
the text 55 -> 244, chars-per-frame CV 2.66 -> 1.44, gap p50 100ms ->
17ms. Real Claude Haiku agent over 52s: 84% of frames advance the text,
CV 1.29, gap p50 and p95 both 17ms (one frame at 60Hz).
Adds a bursty-stream mock model with seeded burst sizes, a gated browser
perf spec, and docs/agent-stream-performance.md.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Address review: safe reveal boundaries and a testable seam
Two findings from the PR review.
Unicode boundaries. Slicing the revealed text at an arbitrary index could
strand a lone surrogate, which paints as a replacement glyph, or split an
emoji ZWJ sequence so a family briefly rendered as its members. Both were
visible for a frame or two mid-stream — the exact flicker the paced reveal
exists to remove. The rendered cut is now pulled back off surrogate pairs,
combining marks, variation selectors, skin tone modifiers, and ZWJ joins.
The reveal counter stays monotonic and only the painted boundary moves, so
a long cluster cannot stall the reveal.
Test placement. The hook test used JSDOM, @testing-library renderHook, and
monkey-patched requestAnimationFrame, all of which docs/testing.md rules
out; it prescribes fixing the missing seam instead. The reveal policy is
now a pure state machine in text-reveal.ts (begin/retarget/advance/
complete/visible) covered by plain unit tests with no environment, and the
hook is reduced to the frame-clock binding. Rendered behavior is left to
the browser spec, which no longer carries frame sampling and statistics in
its body — those moved to e2e/support/helpers/stream-smoothness.ts.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Measure total painted text, not the tail message
The smoothness sampler read the length of the last assistant-message
element. A real turn emits many assistant messages, so that element keeps
changing identity and its length is not monotonic — handovers read as
resets, and a stream that was plainly moving on screen reported almost no
growth at all. Sampling the total across every assistant message fixes it.
This invalidated the numbers previously recorded for the real-agent run,
so the doc table is replaced with a fresh A/B taken with the corrected
sampler against a Claude Haiku agent: frames that advanced the text 6% ->
87%, chars-per-frame CV 4.11 -> 1.86, gap p50 317ms -> 17ms, p95 383ms ->
17ms. The paced column also reported zero Unicode defects across a stream
containing emoji, which exercises the reveal boundary clamp end to end.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* perf(agent-stream): harden paced text reveal
Use standards-compliant grapheme segmentation when available and preserve full arrivals on older runtimes. Cap reveal commits at 60Hz and measure the real browser rendering path so high-refresh displays stay smooth without rendering at hardware frame rate.
* Hold back trailing fragments split by arrival boundaries
The reveal boundary is not the only place a grapheme gets cut. The
daemon's coalescing window ends a delta wherever the window closes, so a
caught-up reveal can be handed half a cluster and paint it. Measured on a
mixed-script Haiku stream: five frames in ~350 ending in a single regional
indicator, i.e. half a flag on screen for a frame.
While the turn is streaming, a trailing fragment that cannot stand alone —
a dangling high surrogate, a trailing joiner, an odd regional indicator —
now waits for the rest of its cluster. Once the turn ends everything
received is painted, because nothing more is coming. Complements the
grapheme-boundary clamp, which handles the reveal cut; this handles the
arrival cut.
The same stream now measures one such frame in ~400 rather than five. The
remainder is a flush boundary that coincides with the end of an assistant
message, putting the two halves in different messages, which the client
cannot rejoin — that one belongs in the coalescer.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
* feat(app): refine workspace pane actions
Prioritize Changes when available, keep Files useful before a selection, and reserve desktop diff clicks for selection so comments require the explicit affordance. Move the Changes shortcut to Shift+G to avoid consuming a likely copy variant.
* feat(app): improve access to workspace changes
Lead with the browser-to-CLI onboarding journey, document the generated starter bundle, and align guided setup with available Hub connections and daemon-reported runtime defaults.
Native multiline inputs were always scrollable, which kept iOS content-size measurement pinned to one line. Keep native sizing intrinsic below the cap and enable internal scrolling only after the composer reaches it.
Keep profiles as the richer primitive while restoring the familiar star cue and clearer guidance for agent selection. Account for borders in shared field geometry so controls preserve their declared heights across form consumers.
Pin the validated provider and protocol SDK dependency pair so fresh installs cannot silently float after a Paseo release. Refresh the Nix npm dependency hash for the resulting lockfile.
Show a concise login success state, scope starter choices to available Hub connections, wait for daemon capabilities, and avoid partial starter bundles when setup cannot proceed.
Large read-only files bypassed the editor's virtualization and eagerly mounted every highlighted token, which could stall the renderer for seconds and consume gigabytes of memory. Route source previews through bounded platform renderers and reject reads above their display budgets before accumulation.
* fix(server): stop short start gates from undercutting provider startup budgets
Agent creation still failed on contended cold starts after #3578, because two
shorter gates on the same path fire before the event-stream readiness budget can
apply. A traced creation reached session.create twelve seconds in, past the 10s
session.create gate, and the agent was then killed by a bare "timeout" at fifteen
seconds from AGENT_RUN_START_TIMEOUT_MS while the SSE connect was still running.
A run-start bound of 15s wraps a path that is deliberately allowed 30s, so the
outer gate always wins and the inner budget is unreachable.
Give session.create the shared OPENCODE_SERVER_STARTUP_TIMEOUT_MS budget, since
creating the first session for a directory is part of OpenCode coming up, and
derive its error message from the constant instead of hardcoding "10s". Raise
AGENT_RUN_START_TIMEOUT_MS to 60s with a comment recording that it must stay
above the slowest provider startup budget; it stays a literal because that module
is provider-agnostic.
Related to #3571.
* test(server): drive the run start budget through a real AgentManager
The run start boundary tests replaced AgentManager.waitForAgentRunStart with a
bare timer, so they never exercised the production run-state transition or the
agent_state subscription the real wait depends on. That path could regress while
the tests stayed green.
Build the scenario on a real AgentManager with a provider session that holds its
turn start open for a configurable span, so pendingRun.started, the lifecycle
change to "running", and the agent_state notification are what the wait actually
observes. Budgets stay independent literals and the settled-before/after
assertions are unchanged.
* fix(server): describe agent run start timeouts
---------
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
* feat(hub): guide setup with provider IDs
* fix(hub): preserve login setup state
* feat(hub): select starter agent runtime
Use the connected daemon's provider snapshot so generated Hub bundles only
contain complete provider, model, and mode combinations.
* fix(hub): stage starter runtime choices
Keep providers with valid modes selectable when the daemon has no default mode, so users explicitly choose the complete runtime.
* fix(hub): scope runtime choices to workspace
Provider capabilities can vary by repository, so request the snapshot for the bundle directory that guided setup will deploy.
* fix(rewind): refresh canonical timeline after rewind
A rewind replaces the daemon timeline and epoch, but replaying the rebuilt history made long conversations visibly reload from the beginning. Capable clients now refresh the existing bounded tail path after a replacement signal while legacy clients retain replay compatibility.
* test(rewind): cover coherent timeline replacement
Hold rewind completion in the browser regression so it can verify the existing pending state and detect any reconstructed timeline rows before the normal tail refresh is allowed to finish.
The filesystem timeline store rewrote each agent's full transcript on every buffered update, causing write amplification that grew with conversation length. Return production timelines to runtime memory and provider-history hydration while preserving the dormant storage interface for a future intentional design.
CodeMirror's mac keymap binds doc start/end to Cmd and gives Ctrl-a the
emacs line-start behaviour, so the Control chords in these specs did
nothing on macOS and only passed on Linux CI.
* fix(app): make default send a dropdown
Keep the compact three-choice setting behind one anchored control while preserving the selected behavior and persistence flow.
* test(app): select default send behavior from its menu
The agent-message submission coverage configures send behavior through Settings, so it follows the dropdown interaction rather than the removed segmented buttons.
The dot was absolutely positioned at the chip's right edge, so it painted
on top of a truncated label. Render it as the last flex child of the tab
content row and widen a modified tab by the dot plus its gap so the label
keeps the room it had.
* feat(app): separate content typography from interface size
Readable prose benefits from its own scale without inflating navigation and controls. Preserve existing users’ rendered size during migration while giving fresh desktop installs a 15px content default.
* test(app): cover independent content typography
The appearance journey now verifies that changing interface size leaves content size intact. Timeline traversal yields when a test deliberately holds the requested page, so the helper no longer waits on its own gate.
* test(desktop): cover independent content typography
The desktop appearance regression follows the renamed Interface control and verifies it does not alter Content size.
* fix(server): preserve Pi turns through retries
Pi reports each provider attempt with agent_end, while agent_settled marks the final user-turn boundary. Keeping modern streams active until settlement prevents a recovered attempt from leaving the chat in an error state.
* fix(server): show Pi provider retries
Automatic retries keep the turn alive, but users still need to understand the delay. Surface Pi's retry-start signal through the existing non-terminal error activity row without changing terminal lifecycle handling.
* refactor(server): clarify Pi retry boundaries
Use the server's object-parameter convention for the retry boundary helpers so their event, turn, and retry semantics stay explicit as the lifecycle evolves.
Adds plugin-contributed light and dark app themes, including persistence, host-aware coalescing, fallback behavior, documentation, examples, and browser/iOS QA.
* fix(app): restore smart Changes comparison selection
The tab migration persisted the selected comparison, pinning Changes to an empty view after checkout state changed. Keep comparison overrides ephemeral per surface and expire them when dirtiness changes, while preserving only presentation state in tabs.
* chore(app): date Changes compatibility cleanup
* fix(app): only surface failed workspace setup
Running and successful setup snapshots were treated as reasons to seed a background Setup tab, adding noise to every workspace with setup commands. Seed only failed setup and classify that tab as main-workspace content so the failure stays visible without stealing focus.
* fix(app): surface failed setup once
Keep the failed-setup surfacing claim with the setup snapshot so closing its background tab remains respected after the workspace remounts. Explicitly prefer the main pane for a new failure tab without moving a user-placed existing tab.
* fix(app): keep setup tabs out of side panel
A preferred main-pane placement can fall back when users remove that pane. Keep that implicit fallback out of the focused Side panel when another workspace pane is available.
* feat(opencode): add active-turn steering via native V2 prompt
Add OpenCode support for active-turn steering (PR #3394) using the
native V2 session prompt with delivery: steer. Implements
steerActiveTurn, pending steer echo correlation for multiple steers,
prompted event handling, and definitive vs ambiguous error handling.
Updates docs and SDK to 1.18.18 for typed V2 access.
* fix(opencode): use V1 prompt for steer so model sees it
V2 prompt was admitted to a separate inbox not visible to the V1
processor, so "How old is bigbird?" was shown inline but never in
the LLM context. Switch steer to session/prompt_async (V1) which
queues while busy and is included in the next LLM call's history.
Update tests to expect V1 and keep the timeline grouping.
* fix(opencode): honor steering permission contract
Admit the steer before denying permissions that block its delivery, matching the provider steering contract. Remove the abandoned V2 path and SDK upgrade because active-turn delivery uses the existing prompt endpoint.
* chore(opencode): preserve SDK patch metadata
---------
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
* feat(app): open summarized tool calls in mobile sheets
Keep compact timelines readable by moving summarized tool-call groups into a live sheet while preserving the existing inline desktop interaction. Individual calls reuse the current detail sheet and stack above the group.
* fix(app): keep mobile tool call sheets current
Restore live group-sheet auto-follow, replace the mocked component coverage with real compact and desktop browser journeys, and remove the touched theme subscriptions.
The sidebar can be pinned to a set of hosts or to a set of labels, but
not to a set of projects. If you keep many projects on one host, the
project is the filter you want.
Add a Project page to the sidebar display menu, next to Host. It
multi-selects projects, and an empty selection means every project.
Apply the filter at two points. Project grouping reads the projects
array, but Status grouping builds its rows from the workspace entries.
A filter applied to only one of the two leaves the other mode
unfiltered.
Apply nothing when a stored selection names no visible project. The
project list is narrowed by the host filter, and it is empty before any
host connects, so dropping keys that do not match would destroy the
selection on every cold start.
Count the project filter in the sidebar header gate. Pinning every chat
in a project drops that project from the unpinned list. Filtering to
that project then removed the header, and the header carries the display
menu, which is the only way back to the Project page.
Generalise the label filter empty state to all sidebar filters, and move
its strings out of the labels namespace.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* fix(server): show Claude usage again on macOS
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(server): keep an incomplete Keychain item from hiding a working one
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* chore(server): tighten Claude keychain fix
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
Codex threads can be archived outside Paseo while their Paseo agent remains active. Repair that provider-state drift during interactive resume while preserving the existing read-only history path for Paseo-archived agents.
* feat(app): let the file panel close its tree rail
The file panel's tree rail was permanent on desktop — the changes panel
had a folder-tree toggle in its toolbar, the file toolbar had nothing, so
the rail was there whether you wanted it or not.
The changes panel's private DesktopTreeToggle becomes a shared
TreeRailToggle so both toolbars render the same affordance; that also
drops the treeToggleStyle prop threaded through ChangesToolbarProps,
which recomputed exactly what the component already built internally.
The i18n keys move out of workspace.git.diff into workspace.tree, since a
shared component reaching into the git namespace would rot.
The two rails keep independent flags — the file rail in panel-store, the
changes rail in useChangesPreferences — so closing one leaves the other
alone. FileTreeToggle wires itself to the store rather than taking a
callback: the bar renders several components below FilePanel, where the
rail actually lives.
Both rails also get narrower (320 -> 260 default, 200 -> 180 minimum).
The persist version bump resets treeRailWidth on purpose: nearly every
existing install carries the old 320 default, so without the reset the
narrower rail would only ever reach new users. Anyone who dragged their
rail to a deliberate custom width loses it once.
Note that MIN_CONTENT_WIDTH still clamps the rail to
`containerWidth - 240`, so in a narrow pane the new default never
applies — that floor, not the default, is what sets the width there.
* feat(app): make the side panel an empty workspace surface
The fixed, hideable side panel keeps supporting context beside an agent without seeding content. Pane-local choices stay local, while implicit supporting opens follow a persisted routing preference.
* test(app): exercise side panel policy through its capability boundary
The browser rail journey already covers the real tree-toggle behavior. Side-panel policy tests now pass their platform capability through the public boundary instead of replacing a production module.
* test(app): expect an empty side panel after upgrade
* test(app): expect an empty first split pane
* test(app): account for the empty side panel in split retention
* feat(app): make workspace panel tabs independent
Give explicit New actions fresh tab instances, keep panel state opaque to tab management, preserve File navigation context, and stabilize uncached File transitions.
* fix(app): preserve file conflicts during preview refresh
Keep the current preview mounted for same-target reads and conversions so the editor retains its conflict observation state. Clear retained previews only when the file target changes.
* perf(app): bound native workspace mount work
Native workspace switches retained inactive React trees and mounted large chat histories in aggressive list batches, making Hermes and Fabric work scale with prior usage.
Retain only the active native workspace, reveal a shared turn-aligned history window locally before daemon pagination, preserve desktop retention semantics, and pace native list mounting.
* test(app): cover bounded history journeys
Exercise local history reveal and hidden outline jumps through the production browser viewport while preserving held-page pagination and scroll anchoring.
* fix(app): reveal fetched outline targets
A bounded merged jump could load its target before the mounted history window, leaving the pending jump unable to scroll. Reveal fetched hidden targets through the existing history-window capability, then scroll once they mount.
* test(app): follow anchored history reveals
History-start helpers could stop at a transient edge before local window expansion restored the content anchor. Retry the real wheel or keyboard action after geometry settles until the timeline remains at history start.
* test(app): wait for history anchor release
* docs(hub): drop the manual auth secret from self-hosting setup
Hub generates its authentication secret on first start and stores it in the
database it is already using, so self-hosters no longer run openssl or carry
PASEO_HUB_AUTH_SECRET between restarts. The variable stays as an override for
deployments that must supply the secret from a platform secret store; Hub uses
it without persisting it, so removing it resumes the stored value.
Also corrects a claim that predates this change: rotating the secret does not
invalidate execution credentials already issued. Hub verifies those against
persisted hashes rather than the secret, so only browser sessions end.
Documentation companion to https://github.com/getpaseo/hub/pull/50.
* docs(hub): lead with the local guided setup
* docs(hub): document XDG data directory
* docs: make Hub self-hosting the quickstart
* fix(server): stop a steered prompt from deadlocking on a permission
Claude parks its turn inside the blocking canUseTool callback while a
plan approval or a tool prompt waits for an answer. The SDK stops to
read its input stream there, so a message sent at the open card is
queued and never read. The user must reject the card or interrupt the
agent by hand, and then send the message a second time.
Deny the pending permissions after the steer is admitted. The prompt is
already in the SDK input queue when the permission callback returns, so
Claude reads it in the same turn. Plan mode stays on and no turn
restarts.
One pass is not enough. A turn that made several tool calls opens its
next request 10 to 300 milliseconds after the previous one is answered,
so a single snapshot advances the batch by one and parks the turn again.
The Claude adapter already tracks the steers the SDK has not read, so
expose that as an optional AgentSession.hasUnreadSteer and answer each
new request while that method reports the prompt as unread. The release
stops the moment the prompt is read, so a permission the agent asks for
after it acts on the message still reaches the user.
Only the two entrypoints that carry a message from a person set the
flag. A scheduled prompt, a child-finished notification, and an
agent-to-agent prompt must not answer a card the user is looking at.
Denial left no timeline row, so a denied plan disappeared from the
transcript. Claude and Codex now push a plan_approval row with
approved: false. For Codex the row comes from resolvePlanPermission,
which is the path that dismissPendingPlanApprovals also uses.
In the app, queue mode held the message until the turn ended, and a
parked turn never ends. The composer now sends while a permission is
pending, and the button label agrees with the submit path.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(server): keep a post-prompt permission out of the release
The release loop answered the pending requests before it asked whether
the agent had read the prompt. The agent can read the prompt during a
poll interval and then open a permission request because of it. The next
pass denied that request, so a decision that belongs to the user was
made for them.
The loop now asks first and answers second. The first pass stays
unconditional, because a provider that does not report unread steers
must still get one release.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(server): make steer permission clearing provider-owned
Keep the daemon policy explicit while letting each provider queue the steer and release permissions that block it. This removes timing-based polling and preserves rejected plans as canonical timeline rows.
* fix(server): preserve omitted steer options
Only construct provider steer options when permission clearing is requested. System prompts otherwise keep the existing undefined options contract.
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
* fix(app): reuse project icon cache in new workspace
New Workspace omitted each host placement's effective icon revision, so its icon queries could not share the sidebar's memory or persistent cache entries. Carry that revision through the target projection.
* refactor(app): centralize project icon targets
Project icon consumers constructed equivalent cache targets independently. One target factory now carries each host placement revision through the sidebar, New Workspace, Projects, and Project Settings.
* test(app): inline project icon fixture
Branch-off creation reused the filesystem slug validator even when callers supplied a separate branch name and worktree slug. Validate branch refs with Git at the worktree boundary so exact valid refs are preserved while path slugs remain restricted.
* fix(app): clear mobile composer input on submit
Submitting a prompt on mobile failed to clear the native text input because EditingTextInput.replaceText previously only called setNativeProps({ text }), which is ignored on Fabric and PasteTextInput.
Fix this by:
- Delegating replaceText in EditingTextInputNative to inputRef.current.replaceText when available, clearing via clear() when text is empty, and falling back to setNativeProps.
- Patching @mattermost/react-native-paste-input to expose replaceText(text, selection) backed by native Commands.setTextAndSelection, updating clear() to use live event counts, and preventing useImperativeHandle closures over null refs during initial mount.
- Registering the patch in scripts/postinstall-patches.mjs and adding unit tests for native text input replacement and clearing.
* fix: update nix npm dependency hash
Upgrading to 0.5.0-beta.1 left the old docked explorer sidebar showing
next to the new explorer pane, with no way to dismiss it.
The sidebar rendered off the persisted `desktop.fileExplorerOpen` flag.
#3287 rerouted the explorer toggle to the workspace-layout explorer pane,
so nothing on a wide layout ever wrote that flag false again, and no
migration cleared it. Assistant directory links and review attachments
could still set it. On Windows and Linux the sidebar's close button was
suppressed because the OS window controls own the top-right corner, so
there was no affordance at all.
The explorer is now one surface picked from two facts — is the layout
compact, and can the platform render split panes:
compact -> slide-over overlay (panel store)
non-compact + splits -> dedicated right pane (layout store)
non-compact, no splits -> ordinary tabs in the focused pane
`workspace-tabs/explorer-surface.ts` owns that choice and every entry
point routes through it, so the toggle, Ctrl+E, Ctrl+., directory links,
review attachments and side file opens can no longer disagree about where
the explorer lives.
Pane splits stay gated on `isWeb`: they depend on dnd-kit and DOM-backed
accessibility helpers. Native tablets are non-compact without splits, so
they get explorer tabs rather than a second pane — deliberately trading
side-by-side agent and Changes for a surface that only renders what the
single-column tab row can actually reach.
Ctrl+. is handled by the workspace screen rather than the app shell. The
pathname alone cannot identify the active workspace: desktop cold-starts
at "/" and restores the remembered workspace from route params, so a key
derived from the pathname is null exactly when a workspace is mounted,
and the explorer reads as permanently closed.
`desktop.fileExplorerOpen` and `explorerWidth` are dropped by a migration
to panel-state v15 but stay declared in the persisted schema. That schema
is a strict object and the validated storage deletes the whole entry when
parsing fails, so removing the keys outright would wipe every upgrading
user's sidebar width, expanded paths and diff-collapse state.
The daemon imports the plugin SDK at runtime, but the workspace was named
@paseo/plugin and marked private, so it was never published. 0.5.0-beta.1
shipped @getpaseo/server depending on a package that does not exist and
`npm install @getpaseo/cli@beta` failed with a 404 for every user.
Two things hid it. sync-workspace-versions only rewrites deps matching
@getpaseo/*, so the "*" range on @paseo/plugin survived untouched into the
published tarball. And `npm pack --dry-run`, which is all release:check and
CI run, lists files without ever resolving the dependency graph.
Renaming to @getpaseo/plugin rather than publishing the old name: @paseo is
not our scope on npm, so that name could never have been published at all.
The rename also brings the package under the version-sync filter, which is
what pins it correctly from here on.
Plugins scaffolded against the old name still import @paseo/plugin. Both
spellings resolve through plugin-sdk-specifiers.ts, tagged COMPAT with a
removal date, so existing plugins keep loading.
This does not repair 0.5.0-beta.1 — a published tarball's dependencies
cannot be changed. The package publishes with the next release.
Preserve ambient placement for reconciliation and background opens while routing user-directed tab creation through the focused visible pane. Defer terminal reconciliation during creates so a push cannot preempt the intended pane placement.
Esbuild reads its binary override during module evaluation, but the previous global environment mutation leaked that host-only path into desktop-launched agents. Scope the compatibility bridge to module loading and scrub the variable from external process environments.
The plugin compiler spawned esbuild through app.asar, and the subprocess
imported the React client barrel. Point esbuild at the unpacked binary,
add a react-free @paseo/plugin/server entry, and pass a typed PluginTheme
so plugin UI follows the active theme on desktop and mobile.
Tapping a subagent or provider subagent from the composer track now opens its tab in the explorer companion pane when desktop split panes are active, keeping the parent agent visible in the primary pane.
Mobile and compact layouts continue using single-pane tab navigation, and cross-workspace subagents continue routing to their own workspace.
The checked-in bundle could not be reproduced from a clean checkout. It
was built from a working tree with hand-modified xterm sources in
node_modules that were never captured as a patch, so it carried terminal
behaviour that exists nowhere in the repository.
It was also one revision stale against our own source: 04fe30a3e flipped
the ResizeObserver call in terminal-emulator-runtime.ts to
shouldClaim: false and never rebuilt the bundle afterwards.
Rebuilding from the published @xterm/xterm picks up that resize fix and
puts the artifact back in sync with the sources anyone can build from.
The Nix grammar package imports @codemirror/autocomplete at the top level
for its keyword snippets, but only declares it as a peer dependency.
electron-builder packs node_modules by walking declared production
dependencies, so it omitted the package from app.asar and the desktop
daemon died on startup with ERR_MODULE_NOT_FOUND. Dev and CI never saw it
because the workspace hoists everything.
This is the same failure that just shipped for Svelte, and the previous
fix removed @codemirror/autocomplete on the assumption Svelte was its only
consumer. Vendoring both remaining grammars removes the class of bug
rather than the current instance: these packages are interactive editor
extensions, so importing one drags a DOM editor runtime into a headless
token highlighter.
The vendored language modules keep upstream's indent and fold props. The
web file editor consumes getLanguageForFile().extension, so dropping them
would have silently degraded editing for .nix and .cs.
The new dependency-closure test replicates electron-builder's traversal
statically and would have failed both PRs. It is scoped to
@getpaseo/highlight because that tree is small and pure; the same walk
over the server tree is dominated by false positives from optional
dependencies loaded behind try/catch.
Both advance-measurement paths measured a line by re-measuring a growing
prefix per grapheme, so one n-grapheme line shaped n^2/2 characters. The
web canvas measurer and the model fallback each carried a copy, and the
native Skia branch had the same shape via getRectsForRange(0, end) per
grapheme. Nothing caps line length client-side, so one long generated
line in a diff pinned the renderer main thread for minutes and the app
read as frozen while compositor animations kept running.
Advance measurement now has one owner in text-measurement.ts: 64-grapheme
chunks measured against an accumulated base, with chunk boundaries snapped
to shaping-safe seams so ligatures and contextual scripts keep their exact
prefix-measured widths (the xfi/Arabic contract in model.test.ts is
unchanged). A 1.2M-char line drops from a ~331-minute projection to under
a second; measurement-cost.test.ts pins the linear shaped-character
budget so the quadratic cannot come back.
The pill bar floats at the foot of the stream section and the composer has
no top padding, so the bar's paddingBottom is the entire pill-to-composer
gap. Desktop was tuned down to 10 earlier; compact kept the original 16 and
read as a hole on phone. Drop it to 8.
Same constant paddings the import pill row, which tightens with it.
observePullRequest used to reveal and focus the explorer pane, and the persisted focus leak then made ambient tab opens (sidebar agent clicks, reconcile auto-open) insert agent tabs into the explorer pane while the main pane sat empty.
PR detection now only background-adds the PR tab to the explorer pane, with no reveal and no focus change, so a tab the user already opened elsewhere stays where it is.
Placement policy is centralized in workspace-layout-actions.ts (resolvePlacementPane / insertNewTabIntoPane): explicit paneId wins, and ambient entity opens (agent, provider_subagent, terminal, draft, browser) reroute from the explorer pane to the primary pane. explorerPaneId is a required input so callers cannot silently fail open.
Pane-rail + affordances thread explicit paneId instead of focus-then-open.
* feat(app): create agent profiles from the model chooser
Restore quick model pinning by creating an agent profile directly from a
model row. A trailing plus on unprofiled model rows opens the pre-filled
profile form; rows that already name the provider+model show the profile
glyph and open it for edit.
- seed the profile form model with provider, model, and suggested name
- expose useAgentProfileEditor from @/agent-profiles for the write path
- hover-refactor model rows per docs/hover.md and add the trailing action
- wire the composer desktop selector and compact model sheet (close-then-open)
Closes#3529
* fix(app): match favourites star position for model row profile action
The star sat after the selection checkmark at the far-right edge. Put the
profile create/edit action in the same slot instead of before the checkmark.
* fix(app): disambiguate per-model create profile action
The per-model + buttons shared the accessible name "Create profile" with
the empty-state row, which broke the empty-host picker test (strict mode
matched five buttons). Name the action "Create profile from <model>" so it
is specific and screen readers get the model context.
* fix(app): keep per-model create label out of model row names
Naming the action "Create profile from <model>" put the model label into a
second button on the row, so regex selectors targeting a model row by name
matched both. Use a generic "Create profile from this model" label that
neither collides with the empty-state "Create profile" row nor with model
row names.
Port the OMP usage poller into the Pi provider so context/token usage
updates during active turns instead of only at completion.
- Add PiUsagePoller with 3s throttle, dedup, and generation-based cancellation
- Wire poller into PiRpcAgentSession turn lifecycle
- Add unit tests for polling, dedup, error handling, and turn completion
Co-authored-by: Slava Goltser <slavikg@gmail.com>
* fix(highlight): vendor pure-Lezer Svelte parser
Replace @replit/codemirror-lang-svelte with a vendored, pure-Lezer Svelte parser so the daemon and desktop app stop pulling the interactive CodeMirror editor runtime.
@replit/codemirror-lang-svelte executes EditorView.inputHandler and HTML autocompletion at module load and depends on @codemirror/view, @codemirror/state, and @codemirror/lang-* as peers. Those peers were not declared by @getpaseo/highlight, so electron-builder omitted them from app.asar and the daemon crashed on startup with ERR_MODULE_NOT_FOUND: Cannot find package '@codemirror/lang-css'.
The vendored parser uses only @lezer/lr, @lezer/common, @lezer/highlight, @lezer/javascript, and @lezer/css, with @codemirror/language kept for the existing Language wrapper. Drops the now-unused @codemirror/autocomplete dependency and adds @lezer/lr as a direct dependency.
* fix(nix): update npm-deps hash for vendored Svelte parser
Spreading ReactNative at module scope evaluated getters for legacy or unlinked modules (such as PushNotificationIOS), which called new NativeEventEmitter(null) and crashed native startup. Pass ReactNative directly to runtimeRequire instead.
After an interrupt, Claude keeps emitting messages about the request it
was told to kill — the notification for the tool it just stopped,
trailing assistant output. The router classified them as new work and
opened an autonomous turn, but the only message that could close that
turn is the interrupt result shouldSuppressStaleResult drops, so the
agent reported running forever.
Pre-existing, but masked until the steer-discard fix: the undiscarded
steer used to resume the turn and its result accidentally closed the
phantom one. pendingInterruptAbort already marks the window where
messages belong to the dying request, so the router now refuses to open
a turn while it is set.
A steer pushed into the live SDK query input survived query.interrupt():
the SDK dequeued it afterwards and resumed the turn the user had just
stopped. Codex clears pending turn input when it aborts a turn; Claude
does not, so its adapter now cancels the SDK messages it queued before
interrupting.
Interrupt-discards is the deliberate ceiling for steer cancellation:
Codex exposes no un-steer operation, so a cross-provider take-back
cannot exist. A steer the model never read keeps its transcript row.
A mixed fan-out used to collapse the pill to its single most urgent
state, so a red dot beside "1 failed" over a child that was still
working read as the whole fan-out having stopped. Every state present
now gets its own mark and count, in the sidebar's status-group order.
Steer is the better default for Enter while an agent is running, but
flipping DEFAULT_CLIENT_SETTINGS alone would only reach new installs.
Client defaults are materialized into storage — the first load writes the
whole default blob and every save re-stamps it — so every existing install
already has "interrupt" on disk, and a stored "interrupt" cannot be told
apart from one the user actually chose. A one-time migration flips them
all; picking "interrupt" afterwards sticks.
The applied-migration marker sits in its own storage key rather than in the
settings blob: StoredAppSettingsSchema is strict and readValidatedJson
clears the value when parsing fails, so an unknown field there would wipe
every setting for anyone who downgrades.
migrateAppSettings owns both of its writes so the settings blob always
lands before the marker. A failed marker write leaves the migration to
re-run harmlessly; a failed settings write must leave the marker unwritten,
or the migration is lost and the user keeps "interrupt" for good.
A running autonomous turn was excluded from steer admission because only foreground ownership was considered. That silently degraded steering into interruption and could race a provider-initiated restart, dropping the prompt.\n\nUse the provider's canonical active-turn identity for admission so autonomous work receives steering on its existing stream without replacement.
The explorer companion pane retention commit (de9e85862) auto-opens the
setup progress tab into the hidden explorer pane. toggleWorkspaceExplorerPane
only seeded the working_diff (Changes) tab when the pane was completely
empty, so once the setup tab landed there, clicking the explorer toggle
could never surface Changes/Files again via that pane's default tab.
Also fixes two e2e testid locators (workspace-new-tab-menu-trigger) and
one unit test assertion (browser automation handler) that assumed a bare
pane root, which predates that same explorer-pane-retention change: the
workspace root is now always a group with a hidden companion pane, so
unscoped locators/assertions must target the visible/named pane.
A resumed native child can be announced under a fresh tool call while retaining its session-scoped task identity. Treat later tool-call identifiers as aliases so lifecycle, usage, and timeline events continue through the original descriptor instead of splitting into contradictory rows.
* feat(app): workspace labels baseline
Existing labels implementation, committed as-is as the baseline for the
UI redesign. Server, protocol, catalog store and sync are sound; the UI
layer is reworked in the commits that follow.
Co-authored-by: Christoph Leiter <christoph@leiter.io>
* feat(app): show workspace labels on the sidebar meta line
Labels could be assigned and then appeared nowhere. They now draw as a
chip on the workspace meta row, after the host, change request, checks
and service, and they can be switched off from the Show submenu like any
other item on that line.
One palette, not two. `baseColors.workspaceLabel` was ten raw Tailwind
500s spanning a 1.9:1 to 4.5:1 contrast range — the exact set
identity-colors.ts was written to replace. Label color is now the
identity color of the same name, so a chip, a host badge and a CI check
on one row sit in one contrast band. `workspace-labels/colors.ts` and its
per-file `labelColorViolet` blocks are gone; `chip.tsx` and `swatch.tsx`
are the only places a label color becomes ink.
A row resolves its labels against its own host's catalog and nothing
else. The cross-host projection answers too, but two hosts may give one
name two colors and the merge settles that by target precedence and then
alphabetical serverId — a row painted in a color its host never assigned
is lying about the one thing the color is for.
The meta row's doc comment argued against tinted chrome on that line. The
chip is a deliberate exception — everything else there reports state and
shares the state colors, a label is a name a person chose — so the
comment now says that instead of contradicting the code.
Alignment:
- The chip is exactly as tall as the line: text at the row's 16pt
lineHeight, no vertical padding, so a labelled row is the same height
as an unlabelled one.
- A label run that opens the line hangs left by the chip's own padding,
landing the name on the rail the title above it uses. Mid-line the
ground stays in the flow.
- The swatch row reaches 44pt through hitSlop growing outward from a 20pt
swatch, replacing ten 44pt colored blobs in the manager modal.
- The picker's raw `<Plus>`, `<CheckSquare>` and `<Square>` go through
withUnistyles with module-level mappings, at the menu's 14pt.
Also fixes a bug this change exposed: `SidebarRowItemsSchema` is a strict
object, so adding an item without a key there fails the whole settings
write and silently loses every other Show toggle with it. A test now
covers each item in SIDEBAR_ROW_ITEMS.
Phase 1 of the workspace labels redesign. The filter menu, the picker's
interaction model and the manager modal's layout are later phases.
Co-authored-by: Christoph Leiter <christoph@leiter.io>
* feat(app): one Labels filter page with tri-state rows
Filtering by label cost three levels: Display -> Labels -> Include -> a page
of labels, or -> Exclude -> the same page again. It is now one page: every
label across every host, one row each, leading with its colour dot.
A row cycles off -> include -> exclude -> off. Include is MenuItem's own
check; exclude is lucide's X, the check's own pair, at the same 16 in the
same trailing slot, so the column does not shift as a row cycles. Exclude
also dims the row, because a state you have to identify by its glyph is not
one you read at a glance. Unlabelled is a row in the same list, with a hollow
ring where the others carry their colour.
Match any / all still appears only once two labels are included. Clear is
absent, not disabled, when nothing is filtered. Manage labels sits at the
bottom below a separator. The whole root row is gone when no host has a
label — unless a filter is still active, since the merged catalog only counts
online hosts and losing one would otherwise strand a filter that is still
hiding workspaces.
The page is titled Labels, so its rows stop repeating the word: "Match any",
"Match all", "Clear filter". The two Match rows sit directly under the list
they qualify and only appear once two labels are included, so the subject is
already on screen. All nine locales move together, translated rather than
transliterated — Arabic keeps its noun, where "أي" is a determiner that needs
one to stay grammatical.
SidebarLabelFilter collapses from four collections plus match to one
Record<labelKey, "include" | "exclude"> plus match: a tri-state row is one
value per label, so include and exclude can no longer disagree about the same
name. Unlabelled keys off the empty string, which normalizeWorkspaceLabelName
makes unreachable for a real label. toggleSidebarLabelSelection,
countSidebarLabelSelections, sidebarLabelSelectionIncludes and
deduplicateSidebarLabelNames go with the old shape; setLabelFilter becomes
cycleLabelFilter and setLabelMatch, so the store owns its own transitions.
No migration: labels are unreleased, so the old persisted shape never
shipped, and migrateSidebarViewState already resets on a safeParse failure.
The e2e manager step asserted "Red label color" on a swatch that Phase 1
renamed to "Red"; the selector follows the code, and the accessibility.color
key behind the old string is deleted from all nine locales as unreferenced.
Co-authored-by: Christoph Leiter <christoph@leiter.io>
* feat(app): assign labels on the menu's own rails
The assign picker was a search box, a create row that unfolded an inline form
under it, and hand-rolled label rows whose 44x44 checkbox pushed every dot and
name off the rail the rows above them sat on. Nothing lined up with anything.
Rows are `MenuItem` now: the colour dot in the 16pt leading slot, assignment as
the trailing check the engine already draws for a chosen row. Using the
engine's row is the alignment fix; there is no second row implementation to
keep in step.
Every press toggles and the menu stays open — assigning labels is multi-select,
so the dual-target model (`source: "row" | "checkbox"`,
`shouldCloseWorkspaceLabelPicker`) is gone along with the model's `close`
dependency. You leave by dismissing the menu.
Search is deleted. Nobody has enough labels to search, and the field was the
thing pushing the list down the surface. `workspaceLabels.search` and
`createNamed` go with it across all nine locales; `clearSearch` stays because
the manager modal still owns a search field (Phase 4's copy, untouched here).
`Create label` moves below a separator at the bottom and opens a page that
replaces the list: a name field, the swatch row, one commit row. Picking a
colour picks a colour — it no longer creates the label. A failed create keeps
the draft and shows the error on the page; a successful one creates, assigns,
and returns to the list with the row ticked.
Both menus share one page definition. `SidebarWorkspaceMenu` and
`SidebarWorkspaceContextMenu` each built the same `pages` memo; they now call
`useWorkspaceLabelMenuPages`.
Menu engine, all of it forced by putting a text input on a menu page:
- `MenuTextField` — the form kit's `FormTextInput` carries a screen's geometry
and lands its text 4pt inside the labels above it. This one is drawn as a
row's own fill, so the box and the text sit on the row's two rails at once.
- `MenuPageDefinition.hoverIntent` — a page you type into is not one the
pointer opens or dismisses. Without it the create flyout opened on a pointer
passing over the row, and threw the draft away when the mouse drifted off it
while the hands were on the keyboard.
- The sheet keeps `keyboardBehavior="interactive"`, not `extend`. With
`enableDynamicSizing` the largest snap point is the content's own height, so
a short page did not grow and the keyboard came up over the field. Caught on
an iOS simulator, fixed, and re-shot.
- `MenuItem` announces `selected` as `aria-checked`. The deleted checkbox said
so; a check drawn and never announced would have been an accessibility
regression. Tri-state rows (the filter page's exclude) still cannot announce
themselves — menu-wide, still Phase 5's.
`sidebar-workspace-row.tsx`'s `isTouchPlatform = platformIsNative || isCompact`
is kept deliberately. On a compact web viewport there is no hover, so the row's
trailing actions and kebab were unreachable and the labels menu with them; this
is the documented `isHovered || isNative || isCompact` shape from CLAUDE.md.
Evidence: /tmp/labels-p3-{wide,compact}-{light,dark}-{list,create}.png, the
create page on iOS with the keyboard up
(/tmp/labels-p3-native-compact-keyboard.png) and the clipped sheet it replaced
(-before.png). `packages/app/e2e/browser/workspace-labels.spec.ts` rewritten
for the new model, daemon-backed failure coverage kept for both the toggle and
the create path, green with the menu unit suites.
Co-authored-by: Christoph Leiter <christoph@leiter.io>
* feat(app): edit a label in one operation
`workspace.label.rename.request` and `workspace.label.recolor.request` become
one `workspace.label.update.request { name, newName?, color? }`. Both old RPCs
are deleted outright — no COMPAT shim, because the labels feature is unreleased
and neither name has ever been on a wire a user owns. The `workspaceLabels`
capability flag stays; that one is how a new app detects an old daemon.
Two calls could land half-applied, which left the catalog in a state nobody
asked for and the UI with nothing true to say. One catalog commit now applies
the name, the colour, or both. Renaming onto a name the host already has is
rejected rather than merged, and the check runs inside the planner so a refused
collision applies neither field. A label keeping its own key is not colliding
with itself, so case-only edits pass.
The manager modal follows:
- The host selector is `HostFilter`, the pill History and Schedules use.
`includeAllHost` is now a prop: a surface that edits one host's catalog
cannot carry out "all".
- Search moves to the sheet header, where it spans the header's own rails.
As a body control it capped at the component's 420pt and left a hole on the
right whenever a host filter wasn't beside it. `workspaceLabels.clearSearch`
goes with the box that had a clear button.
- A row no longer opens an editor. It carries a pencil in its trailing slot,
hover-revealed on web and always visible on native and compact, on the
canonical plain-View hover pattern with the press on an inner Pressable.
- The edit view is a name field, the swatch row, and one Save that issues one
update. Delete keeps its confirm and its affected-workspace count, and moves
to `outline` — red belongs to the dialog, not to the surface that opens it.
- Copy says edit, never rename or recolor, in all nine locales.
`workspaceLabelErrorMessage` drops the `requestType=… code=…` the daemon client
appends to every RPC failure. That is what a log wants, not what a user reads
under a name field.
`workspaceLabels.accessibility.color` stays deleted: the swatch's accessible
name is already the colour's name, so nothing needed it back.
Co-authored-by: Christoph Leiter <christoph@leiter.io>
* chore(app): converge the labels phases
The sweep at the end of the labels redesign: the three items earlier phases
found and kept out of their own commits, one cross-phase pass, and the docs.
`AdaptiveModalSheet`'s close button reaches its target with `hitSlop` instead
of `padding`, which had inset its glyph 8pt from the rail the title, the search
field and every trailing glyph below it sit on. This is shared chrome, so it
moves the close glyph in every sheet in the app; measured on four of them
across both paths — the labels manager desktop and compact, the shortcuts
dialog, the rename sheet — and each one's trailing rail now agrees with its
leading one.
`MenuItem` takes `selected: boolean | "mixed"`. A tri-state row was silent to a
screen reader: the check announced itself after the last phase, but exclude had
no state at all, so an excluded label was indistinguishable from an untouched
one. `"mixed"` is ARIA's own third state, the engine still draws the check only
for `true`, and the row keeps supplying its own trailing mark — nothing in the
menu engine learns what a label is.
CLAUDE.md's platform-gating rules said "NEVER use `onPointerEnter`/
`onPointerLeave`", which is the opposite of what `docs/hover.md` and
`constants/platform.ts` both mandate. It now names the real rule — hover on a
plain `View`, press on a separate inner `Pressable`, and no `onHoverIn`/
`onHoverOut` on a `Pressable` that contains other pressables — and links the doc
that owns it rather than restating it.
The cross-phase pass turned up one real defect: the replica cache never learned
about labels. `StoredWorkspaceSchema` had no `labels`, so a restored workspace
came back unlabelled, and since the directory cursor is current on reconnect the
daemon has nothing newer to send — the chips stayed gone for the whole session
and the filter matched nothing. An optional field, a serializer line, and an
assertion in the restore spec.
Everything else measured onto its rails already. Left alone and logged: the
per-row `Map` in `useWorkspaceLabelDefinitions`, and the display menu closing
when the sidebar swaps to its empty state, which is confined to that swap and
predates every phase here.
`docs/architecture.md` and `docs/data-model.md` described rename and recolor;
they now describe the one update commit, which fields it rewrites, and the
per-host resolution a row's chips use. `docs/menus.md` documents the third item
state where it already documents the other two.
Co-authored-by: Christoph Leiter <christoph@leiter.io>
* feat(app): split the labels filter into include and exclude controls
The Labels page rotated one row through off -> include -> exclude -> off, which billed the rare
operation to the common one: clearing an include cost two presses and routed through exclude, so
the sidebar visibly re-filtered into a state nobody asked for on the way out.
The row now owns include and a control on its trailing rail owns exclude. Each is one press each
way and neither is reached through the other. `cycleLabelFilter` becomes `toggleLabelInclude` and
`toggleLabelExclude`; the store's map shape is unchanged, and setting either state clears the
other for that label, so including an excluded row is still one press.
A row is never both, so the trailing rail holds exactly one 16pt glyph in every state: the check,
a permanent `X`, or the same `X` revealed on hover. `X` rather than a circle-slash for the reason
Phase 2 settled it — `Ban` fills its box where the check does not, and the column twitches. The
control fades rather than mounts and takes its 44pt target from `hitSlop`, so neither revealing it
nor changing state moves anything: measured 453-469 on every row, wide and compact.
The control carries a tooltip, gated to web where hover exists. It portals into the overlay root
above the flyout with no backdrop of its own, and resting on it does not read as the pointer
leaving the surface, so the menu stays up.
Accessibility: the control is a `checkbox` named "Exclude {label}" with its own checked state, and
it stays in the tree at opacity 0 on rows that have no visible mark, so it is reachable without
hover. Keyboard focus reveals it, so tabbing to it never means pressing something invisible. The
row keeps Phase 5's `selected="mixed"` for exclude.
Co-authored-by: Christoph Leiter <christoph@leiter.io>
* feat(app): put include and exclude on their own rails on every label row
Phase 6 gave the row include and its trailing rail a hover-revealed exclude. The rotation was gone
but the awkwardness was not: `X` is the glyph this app dismisses things with, so on a filter row it
reads as delete rather than exclude, and one rail with a changing occupant still means the row's
trailing area keeps changing identity as you read down the column.
Both controls are now on every row in fixed positions, and state is which one is lit. Nothing
appears, disappears, swaps places or shifts: the check box is 429-445 and the ban box 453-469 on an
included row, an excluded row and an untouched one alike, wide and compact. Because the ban is
always there, include -> exclude is one press instead of two. The row still includes, and the check
is that same action named.
`Ban` rather than `X`. Phase 2 rejected it because its ink fills its 16pt box where a check's does
not, so the column twitched when the two swapped on one rail. Nothing swaps here, so what is left
is a weight difference: the ban is drawn at 14 inside a 16pt box, which is the visible optical
correction available before it stops being a glyph. The box is what the rail is measured from and
it stays 16.
Lit is `foreground`, unlit `foregroundExtraMuted`. Not `foregroundMuted`, which is the obvious
choice and is wrong here specifically: the Match rows further down the same page carry `MenuItem`'s
own check in that colour to mean *selected*, so an unlit check in the same ink would say the
opposite thing eight pixels above it. Excluded keeps Phase 2's dimmed row.
Accessibility: the row keeps `selected` — `true` include, `"mixed"` exclude — and the two buttons
carry no checked state of their own, so a label's state is announced once and its two controls read
as the actions they are. Both buttons take a tooltip, gated to web with `enabledOnDesktop={isWeb}`,
because on non-compact native `TooltipContent` falls back to a `Modal` with its own backdrop.
Deleted with it: the `isHovered || isFocused || isNative || isCompact` reveal, the `opacity: 0`
mounting, the hover-tracking `View` and the focus plumbing that existed to keep an invisible control
reachable. Net -7 lines of code on this row, all of the growth in comments. The store is unchanged;
both buttons and the row press map onto `toggleLabelInclude` / `toggleLabelExclude` as they are.
The e2e's hover-reveal and `toHaveCount(0)` steps encoded the deleted behaviour and are replaced by
a rail measurement taken in every state — relative to the row, since filtering moves the anchor the
menu hangs off, and behind a settle poll, since the flyout scales in and a box read mid-animation is
not a rail.
Co-authored-by: Christoph Leiter <christoph@leiter.io>
* feat(app): reveal the label filter's controls, and stop the empty state closing the menu
Phase 7 put the check and the ban on every label row at all times, which made a resting list of
labels read as two columns of buttons with a filter somewhere behind them. A row now shows its own
state and nothing else — a marked row its mark, an untouched row nothing — and hovering any row
brings both controls up, the mark you have lit and the other one unlit beside it. So the column
you read down is the filter, and the row you are pointing at is the one offering to change it.
Nothing about the rails changed. The two boxes are laid out in every state and only their ink
comes and goes: check 429-445, ban 453-469 on an included row, an excluded row, an untouched one,
hovered or not, wide and compact. The reveal is opacity, never mounting, so it cannot move the row
out from under the pointer that fired it. Hover is tracked on a plain `View` wrapping the
`MenuItem` with the presses on the buttons inside it, since `MenuItem` is itself a `Pressable`
(docs/hover.md); each control counts its own keyboard focus as revealed, so tabbing to one never
means pressing something invisible; and `pointerEvents` follows visibility so a hidden control
cannot be clicked. On native and compact there is no pointer, so both simply stand there.
Everything Phase 7 settled is kept: `Ban` at 14 in a 16pt box, `foregroundExtraMuted` for unlit
against `foregroundMuted` meaning *selected* eight pixels below, `spacing[2]` between the two with
`hitSlop` tiling the gap, the row owning `aria-checked` while the buttons stay plain named
actions, and both tooltips gated `enabledOnDesktop={isWeb}`.
---
Excluding a label that empties the sidebar closed the display menu. Logged since Phase 2, narrowed
in Phase 5 to the empty-state swap, owned by nobody.
`SidebarLabelFilterEmptyState` was a component rendered *instead of* the list, with its own
container and scroll view, and both branches were handed the same header element. Different
subtrees, so React unmounted the header on the swap — and the header holds the display menu's
trigger, so the popover went with its anchor. The empty state is now a body: both list modes
render it where their groups would go, inside the scroll they already own and below the header
they already render, and the swap can no longer reach anything above it. Cleared from that same
still-open page, the way back out is the swap in reverse and it holds too.
`ProjectModeList` was already the right shape — its "no projects yet" body sits below the header —
but its header was conditional on `unpinnedProjects.length > 0 || hasActiveHostFilter`, which is
the same bug written as a guard: keep the header when there is a filter to undo. The label filter
joins that condition rather than getting a second mechanism. Both empty states move into
`sidebar/empty-states.tsx` so the two list modes can render either without importing each other,
which also leaves the card's geometry defined once instead of once per body.
Still conditional, and left alone: a sidebar with no projects and no filter has no header at all,
so clearing the last host filter on an empty sidebar closes the menu the same way. That one is the
onboarding empty state, where a section heading over "Add a project" would be a heading over
nothing, and no menu is reachable in the state it lands in.
The e2e gets the step that would have caught this: exclude into the empty state, assert the page
you pressed on is still up, clear from it, assert the same. Verified by reverting both list files
to Phase 7 and watching it fail on "element(s) not found". Phase 7's rail assertions are kept as
they are — offsets within the row, behind a settle poll — and joined by computed-opacity reads for
the four states of the reveal.
Evidence: /tmp/labels-qa-p8/, 28 shots plus INDEX.md — the resting list, a hovered row, an
included row with the pointer away, and the empty state with the menu still open over it, at wide
and compact in both schemes.
Co-authored-by: Christoph Leiter <christoph@leiter.io>
* feat(app): make the label filter include-only
Phases 2, 6, 7 and 8 each tried to give exclusion a comfortable shape inside a 232pt menu row:
tri-state rotation, a hover-revealed control, two permanent buttons, two hover-or-lit buttons.
Every fix bought a new awkwardness, because two controls in one menu row is a lot of machinery for
a filter. The label filter is now the host filter — one tap in, one tap out — and nobody has ever
complained about that one.
A row is a plain `MenuItem` with the colour dot leading and the engine's own check trailing.
Nothing else. Which makes the alignment law trivial to satisfy, and that is the point.
Deleted rather than disabled, since 6, 7 and 8 are in the branch history if exclusion ever comes
back: both trailing controls, the hover-tracking `View`, the `opacity: 0` mounting, the focus/blur
plumbing, the `pointerEvents` gating, both tooltips and their `enabledOnDesktop={isWeb}` gates, the
lit/unlit token pair, `Ban`, and the two rails they held.
`SidebarLabelFilter` collapses onto the shape of `hostFilters` sitting three fields above it in the
same store: `labels: string[]` plus `match`, one `toggleLabelFilter`, one `clearLabelFilter`. The
exclude arm of `filterWorkspacesByLabels` goes with it, and `filter.include` / `includeLabel` /
`exclude` / `excludeLabel` leave all nine locales. Persisted version 4 with no migration — the
record shape never shipped, and `createValidatedPersistStorage` drops state that fails the current
schema before `migrate` ever runs.
Two things this leaves unused, both deleted:
- `MenuItem`'s `selected: boolean | "mixed"`, added in Phase 5 so a tri-state row could announce
itself. The filter row was the only caller, so the union collapses back to `boolean` and the two
`selected === true` guards that existed for `"mixed"` go back to plain truthiness.
- The `docs/menus.md` paragraphs from Phases 7 and 8 on hover-revealing a pressable trailing
control inside a menu row. No caller does that any more, so the doc was describing a pattern the
codebase no longer has.
Kept: `SIDEBAR_UNLABELLED_LABEL_KEY`, because `Unlabelled` is still a row and now includes like any
other; `Match any` / `Match all`, because multi-select include still means two different things.
Phase 8's empty-state fix is untouched and still holds — `hasActiveSidebarLabelFilter` is what the
header's survival condition reads, and it answers the same question against the new shape.
Evidence: /tmp/labels-qa-p9/, 12 shots plus INDEX.md — resting, two selected, and the empty state
with the menu still open over it, at wide and compact in both schemes.
Co-authored-by: Christoph Leiter <christoph@leiter.io>
* fix(app): give label-grouped rows their project icons, and label groups a tag
Grouping the sidebar by label rendered every workspace row with its placeholder
initial. The same workspace under status or project grouping showed its real
project icon.
The components were reused; the data feeding them was not. One component served
both grouping modes and threaded the icon map through correctly. Fourteen lines
above it, a second `useProjectIcons` call was gated on `groupMode === "status"`,
written when status was the only mode that put icons on rows. Phase 2 extended
the render branch to `status || label` and left the gate alone. Nothing forces a
render branch and the data it renders to agree, and these two were far enough
apart to drift in silence. Reuse buys you one implementation of the drawing; it
buys you nothing about who decides what to fetch.
So the fix is the shape, not the line. There is now one `useProjectIcons` call
for every mode, and its targets come from `buildSidebarProjection` — the same
function that produced the rows, from the same `projects` list. A mode can only
render a row for a project the projection already knows about, so the map cannot
disagree with the screen. The gate is gone rather than extended: extending it
would leave the identical trap for the next grouping mode. `useProjectIcons`
dedupes by `serverId:projectId` and caches through `useQueries`, so the gate was
a premature optimization that bought a bug.
Pinned by `sidebar-projection.test.ts`: for each of the three modes, every
project view key the projection will render — pinned rows, grouped rows, project
headers and the rows under them — has an icon target. Red on the old gate:
restoring `groupMode === "status" ? … : []` in its new home fails project and
label grouping with `['project', 'other-project']` uncovered, and status passes,
which is exactly the bug's signature.
The sibling sweep over everything `SidebarStatusModeWrapper` is handed found one
more of the same shape, correct today: the projection chose its shortcut sections
with `status || label`, so a fourth mode would have silently walked the project
rows. Both the groups and the sections now come from one exhaustive `switch` on
the mode, which makes the next mode a compile error rather than an empty list.
`workspaceEntriesByKey` is already written the safe way round (`!== "project"`),
and the rest — host badges, pinning support, the empty-filter state — never read
the mode at all. Two names moved with the shape: `SidebarStatusModeWrapper` is
`SidebarGroupedModeList`, and its branch is `groupMode !== "project"`, so a new
mode lands in the grouped list instead of the project list's `else`.
Also: label group headers draw lucide `Tag` instead of `CircleDot`, in the
label's colour, muted for `Unlabelled` — the glyph the Labels menu and the
workspace menu already use for this feature. It keeps `StatusGroupIcon`'s 14
with no optical nudge, and that is measured rather than assumed: in the header's
leading slot both ink a 14x14 box at the same offset, and the tag lays down 76
ink pixels to the circle's 84, so reaching the corners of its box costs it no
weight beside the status glyphs.
QA: /tmp/labels-qa-p10/ — wide and compact, light and dark, label grouping and
the same workspaces under status grouping, plus the hover chevron swap, the
glyph zooms, and a before/after pair taken with the fix reverted to the bug's
shape.
Co-authored-by: Christoph Leiter <christoph@leiter.io>
* fix(app): align label chip typography
Co-authored-by: Christoph Leiter <christoph@leiter.io>
* docs: format architecture module table
Co-authored-by: Christoph Leiter <christoph@leiter.io>
* fix(app): narrow workspace labels workflow
Co-authored-by: Christoph Leiter <christoph@leiter.io>
* fix(app): mirror bottom sheet test exports
Co-authored-by: Christoph Leiter <christoph@leiter.io>
* test(desktop): assert project browse by destination
Co-authored-by: Christoph Leiter <christoph@leiter.io>
* fix(app): retain empty projects without label filters
Co-authored-by: Christoph Leiter <christoph@leiter.io>
* fix(app): preserve status grouping test contract
Co-authored-by: Christoph Leiter <christoph@leiter.io>
* fix(app): retain filters after label load failures
Co-authored-by: Christoph Leiter <christoph@leiter.io>
---------
Co-authored-by: Christoph Leiter <christoph@leiter.io>
Splitting a pane right from the default layout tore down the existing
pane and rebuilt it, losing the composer, terminal, and scroll state in
it.
Both split reducers derived the parent group from `parentPath.length`,
but `targetPath.slice(0, -1)` is empty for two different situations: the
pane is the whole tree, and the pane is a direct child of the root
group. The second case was read as parentless, so the same-direction
fast path never ran at root level and every split wrapped the pane in a
fresh group. That reparents the pane, and the renderer reconciles panes
positionally, so React unmounted the subtree. The workspace already
starts as a horizontal group holding the main pane and the hidden
explorer pane — the group was there all along, ready to take another
child.
`findParentGroup` now asks `targetPath`, so a same-direction split
appends a sibling into the group that is already running in that axis.
Perpendicular splits still nest, because changing axis needs a group.
The old test asserted the nesting as intended behaviour, which is how
this survived. It now asserts flat insertion, alongside the invariant
that matters: a same-direction split never changes an existing pane's
render path. Node id sequences shifted in three tests because the first
split no longer spends a group id, and four user splits now fit inside
the depth cap where the fourth used to be refused.
The new-tab menu handler registered itself as
`workspace-new-tab-menu:<serverId>:<paneId>`, leaving out the workspace.
Pane ids are namespaced per workspace — every workspace's default pane is
called "main" — so all workspaces on a host produced the same handler id.
The deck keeps several workspaces mounted at once, the dispatcher stores
one live handler per id, and useKeyboardActionHandler registers once per
id and never re-registers on focus change. So the second workspace to
mount evicted the first, and switching back left a mounted, focused
workspace with no handler at all: Cmd+T and Cmd+Shift+A/T/C/E silently
did nothing until the newer entry expired from the deck.
Handler ids are now built from the surface's full identity rather than
assembled at the call site, so the workspace can't be dropped again. The
three workspace-wide handlers move onto the same constructor; they were
already correct, but the shape belongs in one place.
A host can refuse a timeline read for as long as it likes. Codex does
this whenever another writer holds the thread: every catch-up fails with
"already has an active writer" until the other session lets go. The app
retried that on a fixed 1s timer with no ceiling, so an idle window sat
there issuing a request and logging a warning every second, indefinitely.
The refusal is normal lifecycle; the traffic was the bug.
Catch-up and subscription retries now double from 1s to a 30s ceiling,
resetting on success, reconnect, delivery-mode change, and visibility
change so recovery stays immediate once the condition clears.
Backing off alone would leave the user waiting up to 30s with no way in,
so both failure surfaces get a retry they can press. Background retries
stay silent; a retry the user asked for is a fallible user action and
owns its pending state, so "retrying" is a status the sync model
publishes rather than a React boolean.
The blocking "Failed to load agent" screen was the worse half: its load
effect self-gates on the error state, so a refused first resume was
terminal until the user navigated away. Its retry resets the state to
idle, which re-arms the effect.
Finish notifications and agent-target schedules now use the same steering-first delivery path as user prompts. Scheduling still decides whether a busy target should be prompted before delivery begins.
Desktop workspaces need a stable hidden explorer pane so setup and pull-request tabs have a consistent destination without changing the active pane. Mobile keeps pull requests in its explorer sidebar.
* fix(app): preserve IME composition in form inputs
Form model publication was replaying changing default text into the native or DOM editing surface. Keep the editing surface authoritative, defer web publication until composition commits, and reserve resetKey changes for explicit text replacement.
* refactor(app): centralize text editing authority
Route every app text field, including the composer and plugin runtime, through one platform-owned editing primitive. Its type and runtime reject controlled value replay, its command handle owns programmatic replacement, and lint prevents raw React Native TextInput imports outside the boundary.
* fix(app): restore composer placeholder styling
* fix(terminal): let Korean be typed in the mobile terminal
The hidden native terminal input set keyboardType="ascii-capable", which
removes the globe key on iOS — the key that switches to the Korean layout.
There was no way to reach a Hangul keyboard from a terminal at all.
Reaching the layout is not enough. receiveTextChange forwards a text change
only when it extends the previous buffer, and a Korean IME rewrites the
syllable it is composing in place: ㅎ → 하 → 한. Every keystroke after the
first jamo failed startsWith and was dropped, so Korean produced one stray
jamo and nothing else.
Forward a composition edit as a rubout plus the replacement when it rewrites
exactly one trailing Hangul character. Wider rewrites stay swallowed: those
are autocorrect and suggestion replacements, which can touch text the
terminal has already committed.
Hangul also stops travelling the keypress path. The IME reports the jamo it
is composing while the text change reports the syllable, and dispatching both
puts two conflicting characters on the wire.
* fix(terminal): stop composition rubouts after a swallowed replacement
A swallowed replacement advances previousText to text the terminal never
received, so the buffer and the terminal disagree from that point on. A
composition rubout deletes from the terminal, which means it deletes whatever
is actually sitting there rather than the character the buffer thinks it is
replacing: 한글 swallowed to 우리 then rewritten to 우루 emitted DEL and left
한루 on the line.
Latch the disagreement when a replacement is swallowed and keep rubouts off
until the line is cleared, which is where the buffer and the terminal are
known to match again. Appends are unchanged.
* fix(terminal): support CJK IME composition on mobile
* fix(terminal): guard backspace after input desync
* fix(terminal): retain desync guard after hidden input clears
* fix(terminal): preserve Unicode text on Backspace
---------
Co-authored-by: ljy900825-dev <ljy900825@gmail.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
* fix(app): defer composer updates during IME composition
* fix(app): read live DOM value from web composer getText
---------
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
Headless hosts only have ~/.config/cursor/auth.json from a CLI login,
not the desktop state.vscdb token. Without that fallback Settings usage
stayed unavailable on VPS-style machines.
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(skills): manage orchestration skills per host
Agent skills execute on the daemon host, so their bundle, selection, and filesystem convergence must share that owner. Preserve the existing install semantics while letting every capable client manage the selected host and migrating the legacy desktop preference once.
* fix(client): keep skill management internal
Keep host skill management on the low-level daemon client used by the app. Do not expose daemon settings or the desktop-only compatibility migration through the shared Paseo API handed to plugins.
* fix(e2e): declare skills fixture dependencies
Use Playwright's required destructuring form for the test-scoped daemon sandbox fixture so CI can collect the real-host skills suite.
* test(skills): use native managed paths
Assert fixed skill targets relative to the daemon user's real home with platform-native path joining so the ownership contract holds on Windows and POSIX hosts.
Wide layouts make tabs from the tab strip's + menu, so the header menu
listing new agent / new terminal / new browser duplicated it. Compact has
no tab strip, so those actions have to live in the header there.
One menu with an isMobile prop could not express that: the prop only sized
the trigger, and the item list was shared. Two components now, with the
workspace actions (import, copy path, copy branch name, show setup) in one
component both render from the same callbacks so they cannot drift.
The editor section configures the code editor, and the editor view on native is
a "not available here" stub — every control in it was dead. `webOnly` mirrors
the existing `desktopOnly` flag: the sidebar drops the item, and the section
renders nothing if a stale restored route still points at it.
Electron counts as web, so `isWeb` is the gate — desktop keeps the section.
Bulk archive keeps the panel mounted with zero rows while it is archiving or
after a failure — that is the state it lands in once it has archived every
finished subagent. The divider under the action then had nothing beneath it and
read as a hairline stranded at the bottom of the panel.
The archive-finished action sat at the foot of the track panel with nothing
between it and the subagent rows, so it read as one more subagent.
It now sits above the list behind a divider. The pointer enters the panel from
the pill below it, so the top edge is the far end of the trip: reaching a bulk
action costs a pass over every row it affects, and it can't land under a thumb
that was aiming for the nearest subagent.
It stays a row rather than becoming a Button. The panel is a menu surface, and
a bordered control inside a list of menu items reads as a foreign object — the
divider does the separating, not the chrome.
The pill drew the most urgent state of the fan-out next to the size of
the fan-out. Two questions, one pill, 8pt apart: a spinning ring beside
"2 subagents" reads as two agents running when one is. The mark is the
thing the eye lands on, so its state gets read onto whatever number is
next to it.
While anything is happening the pill now names that state and counts the
children in it — "1 running", "2 failed" — and falls back to naming the
total once nothing is. The total is a tap away in the panel, and the
running count is the number worth glancing at anyway.
Label and mark are built by one function so they cannot disagree; the
call site takes both or neither. Every non-done bucket gets copy, not
just the two that are reachable today, because needs_input and attention
become reachable the moment a subagent row carries requiresAttention,
and the failure mode is silently the bug this commit fixes.
Keep the entry point focused on contribution wiring so client UI and daemon handlers can use their own runtime APIs safely. Surface compilation failures and lifecycle transitions in the retained plugin log tail.
Track pills became menu surfaces when the trackers moved out of stacked
cards, but ComposerTrackRow kept the raw Pressable it had as an inline
card row. Nothing in that path reaches selectItem, so the popover or
sheet stayed parked over the transcript after opening a subagent, and on
iOS the action fired while UIKit was still tearing the sheet down. Rows
now hand the press to the engine and take closeOnSelect, as MenuItem
does; bulk archive opts out because its progress lands in the panel.
The running mark is the shared StatusRing rather than a still dot the
pill drew for itself, and the remaining dots read their colour from the
one bucket-to-colour map. The slot is sized for the ring in every state
so the label does not step sideways when the last child finishes.
Mounting the real menu engine in a test needed harness work: stubs for
two packages that ship untranspiled Flow, reanimated left out of Vite's
prebundle so its .web files win, and the gaps filled in the unistyles
test theme.
Layout measurement excluded pill margin on some renderers, collapsing the intended gap above visible tracks. Keep the two-pill rail on one line and derive its complete tail clearance from shared geometry so chat switching and layout callbacks cannot change the result.
Use noun labels in the add-tab menu so creation targets read consistently alongside Changes, Files, and Pull request. Keep verb-oriented labels in shortcut and command surfaces.
Keep the roomier 16px separation on compact layouts while using 10px on wide layouts. Apply the same breakpoint contract to track and import-session pills so their spacing stays consistent.
The empty-state text inherited the platform default color, making it unreadable on dark surfaces. Use the app foreground tokens so it follows the active theme.
Profile placeholders were reaching spawned processes because each menu decided whether to resolve them. Keep UI callers profile-only and perform empty-prompt resolution once when the workspace terminal is created.
Treat the appearance setting as the rendered base size so the type ramp scales predictably. Native starts one pixel larger while web and desktop preserve the authored 14px base, with legacy values migrated on load.
Keep agent and task pills as a transparent transcript overlay while measuring their rendered height for scroll-tail clearance. Share geometry and spacing with the import-session pill so borders, type, corners, and composer separation match across platforms.
Manual activation now runs once while the recognizer is waiting; reactivating or failing an already-active recognizer could leave the gesture pipeline stuck.
Cmd/Ctrl+T now opens the focused pane's launcher, while explicit target shortcuts bypass the menu. Dispatcher ownership is scoped to the app tree so retained workspaces cannot intercept commands.
The editor was trapped below the resident webview's body-level paint plane in split layouts. Render it through the shared overlay root while tracking the owning pane's bounds.
The selected segment was an inverted fill — theme foreground behind
surface0 text — which in dark mode dropped a white slab into thin
chrome like the file toolbar, and the fully rounded corners made it
read as a lozenge rather than a tab. It now uses a surface3 fill with
foreground text, and each segment takes the corner radius of the
button at its size tier.
Segments also drop one padding step below the equivalent button. The
gap between segments already supplies the visual padding, so matching
button padding made the track wider than it needed to be and ate the
breathing room around it in a 32px toolbar row.
Selection changes colour only. No weight or size change, so labels
never reflow as you switch segments.
Carries the buttonControlHeight extraction that was already sitting in
control-geometry.ts, since the two files could not be split apart
cleanly for this commit. Its callers stay uncommitted.
Pull request auto-placement was using the current location of Changes as a proxy for pane ownership. Keep the registered explorer pane authoritative so moving Changes cannot redirect automatically discovered pull requests.
Keep the last painted canvas width while a pane resize is active, then repaint during layout before the settled width reaches the screen. This avoids scaling the bitmap during the drag or on its final handoff frame.
Fast Refresh preserves refs while rerunning effects, so canceled paint requests must release their retained IDs or unseen diff regions stop rendering. Paint the final expanded body’s reserved boundary pixel because no following header owns that seam.
Reuse measured diff geometry when review controls open, keeping the interaction responsive on large changes. Preserve complete tab-track snapshots while exact content widths settle so new tabs and retained workspaces do not flash or truncate.
Keep diff headers and painted content on the same scroll clock while preserving native scrolling and independent per-file horizontal offsets.
Complete review, selection, typography, tree, and palette parity without returning line rendering to React.
Size tabs from rendered labels and switch to horizontal overflow once minimum click targets are reached, keeping the add-tab action adjacent until it must remain fixed.
Make Changes an always-expanded document with an optional desktop navigation tree, keep Git actions in their platform-owned headers, remove pinned and inline split shortcuts, and place review tabs beside Changes.
* feat(plugins): add workspace panels and command center contributions
Give local plugins contextual UI entry points without making the daemon interpret client placement or callbacks. Panels receive stable IDs and read cached client state through required selectors with shallow equality, avoiding unrelated guest rerenders and context-discovery RPCs.
* fix(plugins): align shared client state ownership
Workspace panels and Command Center callbacks consume the same frozen client snapshots, so their adapter belongs to the plugin boundary rather than either contribution surface. Refresh the Nix dependency hash for the selector-store dependency added by this feature.
* fix(app): keep check rows the same height with and without "Add to chat"
A check row was minHeight 32 plus 8pt of vertical padding, but the xs
Button in its trailing slot is 28pt tall. The button stacked on the
padding rather than fitting inside the row, so a row grew to 44pt the
moment "Add to chat" appeared and the list visibly shifted as rows
gained the button.
Rows are single-line by construction, so the row now declares a fixed
height instead of a floor a child can outgrow. That height comes from
CONTROL_HEIGHTS, the same source the button sizes from, so the two
cannot drift apart later. GitLab pipeline rows share this style and get
the fix too.
* feat(app): summarize pull request checks and group them by status
The checks section listed every check inline under a bare "Checks"
header with three count pills. On a repo with a real matrix that meant
a wall of undifferentiated rows that pushed the activity timeline off
the pane, and the pills made the reader add up numbers to work out
whether the run was actually in trouble.
The section now leads with the answer — a headline that names the
outcome, a one-line count breakdown, and a ring showing the statuses in
proportion — then groups the rows by status with failures first, and
scrolls in place past roughly eight rows instead of growing without
bound. Groups collapse individually.
The derivation lives in checks-summary.ts as one pure function, so the
headline, the ring, and the group labels cannot disagree about the same
run. The rendering, the row, and the check identity move out of pane.tsx
into an owned module; the pane now wires it and nothing more.
Running checks also gain a timing. formatCheckRunDuration required both
a start and a completion, so anything still going showed nothing at all.
It now measures an unfinished run to now. The wire deliberately carries
formatted strings rather than raw timestamps — there is a test asserting
check timestamps are stripped — so the daemon does the measuring and the
client only decides how to say it: a bare "2m" on a running row would
read as "took 2m", so those rows say "running 2m".
* fix(highlight): highlight Svelte components
`.svelte` had no entry in the extension table, so `highlightCode` fell through
to one unstyled token per line. That is the same table the file pane, the diff
tokens, markdown fences and the web editor all resolve through, so a component
rendered as plain text everywhere in the app.
Register `@replit/codemirror-lang-svelte`. Its `svelteLanguage` already wires
the mixed parsing for the `<script>` and `<style>` blocks, so the script gets
JS/TS roles, the style block gets CSS roles, and the template gets tag,
directive and mustache roles. It comes from the same publisher as the
`@replit/codemirror-lang-csharp` entry already in this package, and only the
grammar itself is declared: its CodeMirror peers resolve the same way the C#
grammar's peers already do.
* test(server): cover Svelte diff highlighting
The review surface in the issue is the diff, and the daemon is what tokenizes
it (`diff-highlighter.ts` calls `highlightCode`), so the regression belongs
here too: script, template block and markup roles on a `.svelte` hunk.
System-injected prompts never enter the Paseo timeline, but their provider runs keep distinct canonical turn IDs. Keep those IDs for lifecycle correctness while grouping adjacent output without a visible prompt into one response.
The subagents track and the task list were stacked cards above the
composer. Each one pushed the composer further down the pane and painted
an opaque band the timeline stopped at, and both spent a full row on a
count that fits in a pill.
Both are now pills that open a panel — a popover on wide screens, a sheet
on compact ones, from the existing menu engine rather than a fourth
floating-panel implementation. The row frame is shared, so a subagent row
and a task row sit on the same rail whatever their contents.
The bar mounts inside the transcript's animated container and pins itself
to the bottom edge, so scrolled content passes under the pills instead of
stopping above them. It stays a child of that container rather than a
sibling floating over the composer: on Android a view outside its parent's
bounds paints but takes no touches, and every pill is pressable. The
container carries the same keyboard transform the composer does, so the
pills track the composer's top edge when the keyboard moves.
The tracker state moves out of the composer with it. None of it was
composer state; it lived there because that is where it used to render.
* feat(cli): manage projects
Expose the existing project lifecycle through one CLI command group so projects can be managed without opening the app.
* fix(cli): preserve remote project paths
Project paths belong to the daemon when a target is explicit. Require that path instead of resolving the CLI machine's cwd, and keep local convenience resolution for the default connection.
* docs(cli): document project management
* docs(cli): remove repeated project reference
Settings detail screens must return to their logical parent regardless of the route used to enter them. Keep compact navigation in the header while preserving the desktop project-list affordance.
* feat(app): add persistent workspace side panes
Keep the common review and file-browsing workflow beside the active agent without tearing down pane contents. Workspace tabs now share one compact chip language, assistant file links route into the retained right pane, and diff/file trees share stable geometry.
* fix(app): localize pull request empty state
* test(app): update explorer pane coverage
* fix(app): preserve explorer pane workflows
* test(app): make explorer tab selection idempotent
* fix(app): persist workspace review pins
* fix(app): preserve sidebar scrim drag hook
* fix(app): restore companion pane interactions
* test(app): follow selected sidebar surface
Give selected rows their own semantic surface so selection remains visible through hover while badges, status rings, and action scrims inherit the correct backdrop.
Compact bottom sheets were still using the legacy independent-scroll gesture path, which intercepted Android sheet pans and row taps. Keep that path only for the desktop split viewport and let compact model browsers participate in sheet gesture handling.
* perf(app): keep composer typing responsive
The controlled web textarea pushed every keystroke through the draft store and broad composer tree before paint. Keep live text in the focused DOM input and publish draft and selection state after paint so persistence and reactive features remain intact without blocking the typed character.
* fix(app): preserve live composer state at actions
* fix(app): use live input for autocomplete clicks
* fix(app): keep submit activity atomic
Use the shared interface text tier across the workspace header, conversation, and composer so the working surface no longer shifts between adjacent font sizes.
* feat(agents): add active-turn steering
Let users choose whether an immediate prompt interrupts or joins the active turn. The daemon owns exact-turn admission, provider delivery, safe fallback, and canonical timeline identity while queueing remains client-side.
* fix(agents): stabilize steering history and replay QA
* fix(agents): preserve steering timeline order
* fix(agents): reconcile resumed provider history
* fix(agents): serialize steering with cancellation
* test(agents): prove Claude multi-tool steering
* test(agents): force explicit history hydration
* fix(agents): flush output before steer admission
* feat(plugins): expose Paseo API to local plugins
Plugin surfaces borrow the selected host connection while subprocess handlers receive an exclusively owned IPC session, so plugins can use normal workspace and agent operations without inventing another transport.
* feat(plugins): reload global enablement safely
Treat the global plugin switch as a live daemon setting and teach plugin automation to inspect it before acting. Enabling remains permission-gated because plugin backend code can access the daemon machine and client contributions execute inside the app.
* feat(plugins): expose plugin logs
Capture plugin stdout and stderr separately from IPC so authors can debug initialization, RPC, cleanup, and crash behavior from the app or CLI. Keep a bounded tail across lifecycle recovery while preserving structured output in the daemon log.
* fix(build): order plugin dependency builds
* test(plugins): align startup and CLI expectations
Use an overlay scrollbar for desktop chat timelines so scrollbar gutters cannot move centered content. Share its behavior with existing workspace scrollbars and derive resting and hover colors from semantic foreground tokens.
* perf(app): reduce warm workspace switch work
Retained workspaces avoided mounts but pane activity still rerendered the full composer, message input, and agent-control presentation. Keep the activity contract intact while moving focus-sensitive behavior behind small context consumers, and add a Playwright benchmark that measures rapid retained workspace switching on one browser clock.
* perf(app): accelerate retained workspace switching
Avoid repeated retained-stream layout reconciliation and unrelated workspace structure recomputation. Use the lightweight route transition while the host is already focused, while preserving targeted root POP_TO for returns from app-wide routes where it prevents duplicate hidden decks.
* perf(app): defer composer focus during workspace switches
* perf(app): retain recently used workspaces longer
Keep up to ten workspace screens warm so normal multi-workspace navigation does not repeatedly rebuild recently used timelines. Inactive screens expire after ten minutes, while the active screen remains protected and capacity eviction stays least-recently-used.
* fix(app): reconcile retained timeline geometry
* fix(app): refresh retained reading position
Let users opt into branch and project context on workspace rows while keeping the default sidebar quiet. Project names follow the existing project-icon visibility rule so grouped rows do not repeat their parent.
* fix(opencode): recover turns after event stream drops
Own the global event transport for the lifetime of the OpenCode server generation instead of coupling it to individual turns. Reconcile scoped provider state after reconnect so transient EOF, errors, and stalls do not falsely fail healthy foreground or delegated work.
* fix(opencode): settle recovery lifecycle edges
Retry only failed post-gap snapshots, preserve terminal child evidence when status is unavailable, and treat provider process exit as an authoritative stop boundary.
* fix(opencode): keep gap repair snapshot-scoped
Refresh child and blocking-request state once per reconnect, retry only rejected root snapshot reads, and settle a successful snapshot without the active dispatch instead of polling transcripts.
* fix(opencode): keep live ingress ahead of repair retries
Schedule failed snapshot retries back onto the serialized session ingress so newly arrived live terminal events run first. Keep retry backoff scoped to root reads and invalidate stale retries across later reconnects.
* fix(opencode): invalidate stale gap repairs between turns
A delayed recovery retry could inspect a replacement turn after the original turn completed. Turn creation now invalidates older repair work, and the slash-command timeout regression drives the generation-owned event source used by sessions.
* fix(opencode): bind gap repair to its turn
A queued or in-flight repair could resume after its original turn ended and read a replacement turn's mutable dispatch state. Recovery now captures its boundary and revalidates the repair revision after provider awaits and while materializing snapshots.
* fix(opencode): preserve child status on failed recovery
* fix(server): bind auto-archive to workspace PR state
Managed worktrees previously derived PR status from whichever branch was currently checked out, so checkout drift could attach an unrelated merged PR to the sidebar and archive flow. Pin PR lookup identity at worktree creation, fail closed on drift, and fan the same fresh PR snapshot shown in the sidebar to every workspace sharing that cwd.
* fix(server): serialize shared workspace auto-archive
* refactor(server): archive from one fresh git observation
* perf(app): bound large diff row mounting
Keep the existing vertical and horizontal scroll owners so virtualization does not change gesture or scrolling behavior. Reserve exact offscreen row heights while mounting only the visible window, and retain the full renderer when wrapping or editing reviews requires dynamic geometry.
* perf(app): keep large diff reviews responsive
Opening an inline editor changed the whole review action object, reconciled large syntax-token trees, and disabled row virtualization. Keep the expensive token subtree stable and preserve deterministic row geometry while the editor is open.\n\nBound server-side highlighting for pathological single lines and remove the unused client highlighter so the actual diff pipeline owns the limit.
* test(app): check mounted diff typography
Row virtualization can unmount a fixed diff row after scrolling. Assert the configured font size against a currently mounted row so the test continues to verify rendered typography without depending on window position.
* test(app): stabilize Mermaid streaming coverage
* feat(daemon): reload configuration without restarting
Keep config validation, reloadability classification, override handling, and atomic runtime application inside the daemon so local and remote clients share one authoritative workflow.
* fix(daemon): preserve reload resource ownership
Keep every materialized provider client under daemon-lifetime ownership and classify speech overrides from resolved providers. Restore the complete WebSocket config-store test contract exposed by CI.
* feat(app): archive finished subagents together
Make the track action clean up finished managed and provider-owned children as one operation while preserving running work. Managed archives remain sequential and provider-owned dismissal stays client-local.
* test(app): clarify subagent archive assertions
* feat(plugins): add local plugin surfaces and RPCs
Keep plugin execution scoped to its daemon while allowing one local TypeScript entry point to contribute validated RPCs and native client surfaces. Paseo owns host selection, navigation chrome, and per-installation query caches.
* feat(plugins): add Linear attachment source
* fix(nix): refresh npm dependency hash
* test(server): exclude internal sessions from Hub counts
* test(server): distinguish Hub execution sessions
* feat(plugins): make local plugins explicitly opt-in
Keep plugin loading dormant until configuration enables it, and collect unsupported examples under one directory.
* refactor(plugins): establish host boundaries
* fix(nix): refresh plugin dependency hash
* fix(plugins): complete packaging and startup rollback
* feat(plugins): add managed local lifecycle
Make trusted local plugins recoverable and authorable through daemon-owned lifecycle operations, host settings, and a capability-gated CLI. Reloads fully stop the previous plugin before starting new code, while generated projects provide strict TSX checking without changing runtime dependency ownership.
* fix(nix): refresh plugin dependency hash
* perf(timeline): resume cached history from its exact range
Warm launches were repainting cached history and then downloading the same bounded tail to rediscover that nothing changed. Persist synchronization authority only when the complete current canonical window can be encoded losslessly; otherwise retain the existing display-only fallback.
* test(timeline): hide replica setup behind E2E helper
* fix(timeline): bound persisted resume overflow
* test(timeline): cover workspace eviction catch-up order
* test(timeline): read persisted range from IndexedDB
* fix(app): preserve native composer input semantics
Native text was round-tripped through application state on every keystroke, which could overwrite an input method's in-flight edits. Keep native text ownership inside the input and reserve application-driven replacement for explicit draft mutations.\n\nRoute native clipboard images through the existing persisted attachment pipeline so keyboard and system paste actions behave like other image sources.
* fix(nix): refresh dependency hash
The native composer dependency changes the fixed npm dependency closure used by desktop builds.
* fix(app): keep composer locked across image pastes
Count concurrent native image persistence operations so submit remains disabled until every pasted image is attached.
* fix(app): replace composer text through the DOM on web
Web TextInput refs are DOM elements, so application-driven draft replacement must update their value and selection without native setNativeProps.
* fix(app): preserve web IME composition
Carry the Chinese IME fix from #2990 into the web composer input boundary.
Co-authored-by: Jimmy Lee <jimersylee+github@gmail.com>
---------
Co-authored-by: Jimmy Lee <jimersylee+github@gmail.com>
* fix(desktop): keep element selectors available on loaded pages
Annotate element and Screenshot element returned early when a cached load signal said the guest was not ready, even if the current document was complete and executable. Let executeJavaScript own the readiness boundary and keep selector cleanup available in the same stale-signal state.
Extend the real Electron browser regression to reproduce the complete-document/stale-signal mismatch through both toolbar actions.
* test(desktop): await browser selector injection
Poll the guest selector state through the browser bridge instead of assuming injection completes within 250 ms. This keeps the Electron regression deterministic on slower CI hosts.
* Harden browser selector session lifecycle
* Keep selector E2E fixtures independent
* fix(desktop): harden selector and annotation lifecycle
* fix(desktop): keep selector PR focused
* refactor(desktop): own element selector lifecycle
---------
Co-authored-by: Daniel G. Kang <180234654+dgk-dev@users.noreply.github.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
Reuse the existing workspace drag interaction for pinned rows and persist their local order across grouping modes. Keep pressed and dragged row scrims aligned with the active backdrop, and preserve memoized row rendering on web.
* feat(sync): restore host directories before reconnect
Persist complete project, workspace, and active-agent replicas so every registered host can paint immediately, including offline hosts. Reconcile those replicas through monotonic per-entity cursors on the existing directory RPCs without retaining an event journal.
* fix(sync): preserve active turn identity during catch-up
Own Agent-to-wire projection beside the existing snapshot normalizer so replica persistence does not reach through directory-sync internals. This also keeps identified active turns intact when unchanged agents are folded into an incremental directory response.
* test(e2e): inspect the real IndexedDB replica
Keep cache setup, reload, and hydration coverage on the production browser storage backend after the replica moved off localStorage. Measure writes at the actual IndexedDB put boundary as well.
* fix warm reconnect cache paths
* feat(sync): persist project icons and deepen directory sync
* fix(sync): preserve project update order
* fix(cache): preserve validated replica persistence
* fix(sync): retain cached agents during catch-up
* test(cli): stabilize live relay status coverage
The regression fixture deleted the supervisor's active PID lock to model a reachable daemon without local process state. Under CI load, worker readiness could race that deletion and make the supervisor tear down the daemon while status and pairing were probing it.\n\nLaunch the real worker directly so the no-PID state exists naturally, then wait on the CLI-visible live relay state before asserting behavior.
* test(cli): wait for complete relay probe state
Status readiness alone does not guarantee the next pairing RPC or the foreign-home identity probe will finish inside the CLI's per-call timeout under CI contention. Retry each exact observable outcome within the fixture deadline and resolve the real worker entry through the server package.
* test(cli): name retry mechanics once
Keep the relay scenarios expressed as CLI probes and exact outcomes while one file-local helper owns the shared deadline, worker-liveness check, and retry cadence.
Running chats expose one provider, so an intermediate provider screen and back action are redundant. Restore the direct model-list entry behavior while preserving the multi-provider picker.
Project icon discovery could select an ICO that native clients cannot decode even when a renderable SVG or PNG was available. Keep ICO as the final fallback and recognize common sized icon names.
Provider catalog discovery can exceed one minute during cold startup. Increase the default deadline while preserving explicit config and environment overrides.
* fix(app): keep profile modes scoped to providers
Applying a profile through sequential field setters allowed a provider change
to race mode persistence, poisoning modeless provider preferences with another
provider's mode. Apply profiles as one form transition and heal invalid
provider-scoped mode preferences when profiles are used.
* test(app): cover profile features across providers
* feat(cli): add Hub init wizard
Reuse the existing login, daemon enrollment, project listing, and deploy paths so onboarding stays compatible with current Hub servers. Generated triggers require an explicit sender allowlist and existing bundles are never modified.
* fix(cli): stop Hub init until daemon is ready
A durable Hub relationship can be reconnecting without being ready to execute workflows. Keep enrollment skipped, but stop the wizard until daemon status reports connected.
* fix(cli): wait for Hub daemon readiness
Both fresh enrollment and reconnect paths can expose a durable relationship before the execution socket is connected. Poll the existing status RPC with a bounded wait before resolving projects or deploying.
* fix(cli): make hub init detect resolvable resources
* fix(server): trust healthy workspace git watchers
Quiet observed workspaces were periodically scheduling full Git refreshes, saturating the daemon-wide process pool as workspace count grew. Keep only cheap observation re-ensure work, refresh ignore paths from watcher events, and detect silent metadata watchers with a one-shot canary before accepting the subscription.
* fix(server): refresh watcher ignores from git metadata
Repository exclude and Git configuration changes also affect the standard ignore set. Route those metadata events back to linked working-tree observations so their excluded directory lists stay current without periodic Git polling.
* test(server): drive ignore updates through watcher events
The integration contract now exercises the production event-driven ignore refresh instead of waiting for the removed periodic audit.
* fix(server): normalize git metadata paths on windows
File observation returns platform-native relative separators. Normalize those paths before matching Git metadata names so repository exclusion changes refresh working-tree ignores on Windows too.
* fix(server): trail concurrent ignore refreshes
Remember ignore-source events that arrive during an in-flight Git query and recompute once more before considering the working-tree observer current.
* fix(providers): make catalog refresh deadlines configurable
Provider startup can legitimately exceed a fixed short timeout when catalog discovery has substantial initialization work. Give each provider refresh one configurable deadline covering availability and the complete catalog probe, and cancel named child operations before allowing retries so timed-out work cannot accumulate in the background.
* fix(opencode): abort catalog server acquisition
A provider refresh could expire while the helper server was still starting because acquisition only tracked the activity without observing its abort signal. Reject acquisition before transferring a server reference, while leaving the manager-owned startup reusable by the next caller.
* test(file-observer): allow native burst recovery
* fix(hub): make daemon disconnect unilateral
Local relationship removal must not depend on reaching the Hub. Fence execution authority, make one bounded courtesy revocation attempt, then clear local state so status and reconnect eligibility agree immediately.
* fix(hub): preserve disconnect intent across crashes
Persist the terminal disconnect marker only while the bounded Hub notification is in flight. Startup removes that marker, so a crash cannot restore the old relationship or create a revocation retry loop.
* fix(claude): preserve native MCP timeouts
Paseo forced Claude MCP startup and tool waits to ten minutes, turning unavailable servers into long silent first turns. Let Claude apply its own defaults while preserving explicit user environment overrides.
* test(claude): isolate MCP timeout regression
Exercise timeout precedence through the existing provider runtime environment input without mutating process-wide test state.
* test(workspace-git): await refresh boundaries
Synchronize fetch observation tests with initial snapshot and fetch completion before advancing fake timers.
* fix(app): validate persisted client state
Persisted replica data could satisfy TypeScript through an assertion while missing fields required by renderers. Treat every AsyncStorage value as untrusted input, clear values that fail strict schemas, and version the replica DTO so incompatible caches cannot hydrate.
* fix(app): preserve validated legacy state
* fix(app): preserve validated preferences
Preserve supported legacy location fields and every shipped theme while keeping unknown persisted fields rejected. Align browser test seeds with the current preference shape so strict validation exercises the intended provider.
* fix(app): migrate legacy agent preferences
Released clients stored host/location fields and per-provider thinkingOptionId together. Migrate those exact strict bytes into current preferences without admitting unknown fields.
* fix(app): preserve drafts with legacy reviews
Accept the exact strict shape released for workspace review attachments so migration can discard that obsolete attachment without clearing every persisted draft.
* fix(worktrees): preserve selected base config
New worktrees were overwriting the selected ref's paseo.json with source-checkout bytes, which could create an accidental revert in an otherwise fresh workspace. Keep committed base config authoritative and warn when locally saved setup changes still need to be committed.
* fix(worktrees): seed config exclusively
* fix(projects): refresh config status on focus
Todo timeline entries gained required activity metadata in beta.1 while the persisted cache format remained unchanged. Reject version 3 snapshots so upgrades hydrate authoritative state instead of rendering legacy rows.
* fix(subagents): preserve opened children on parent archive
Opening a managed child represents user ownership only while that client's tab remains open. Track that intent per client, detach surviving children during parent archive, and keep single and bulk tab-close behavior consistent.
* fix(subagents): mark generic tab openings
Centralize managed child tab ownership in agent navigation so list, command-center, deep-link, notification, and subagent-track openings all mark ownership before creating the tab. Use stable localized errors for lifecycle failures.
* fix(subagents): classify cold tab openings
Fetch uncached agents before deciding whether tab ownership must be recorded, so notification, desktop, and cold-route openings cannot bypass parent archive protection.
* fix(app): translate navigation fallback errors
* fix(navigation): allow offline workspace intents
* refactor(app): derive subagent ownership from open tabs
* fix(server): serialize subagent lifecycle ownership
* fix(providers): restore implicit model variant selection
Provider catalogs list only explicit overrides, so treating the first override as the default made base model behavior unreachable. Represent the base choice in the catalog and translate it back to an omitted variant at the provider boundary.
* test(providers): exercise variant catalog boundary
The regression concerns the model catalog callers receive, so verify normalization through fetchCatalog instead of coupling the test to its internal builder.
Keep trailing edit controls from changing section heading rhythm, and center each profile's icon with its own text block so one-line and noted rows share the same visual alignment.
Restore the established tree rails while retaining file actions. Keep Escape local to generic editable fields so cancelling a file draft cannot interrupt an agent.
The subagent track used a custom hover path that left its label and chevron muted. Reuse the ghost button interaction so track headers promote their content consistently.
Workspace terminal shutdown only signaled an npm wrapper, leaving Electron alive with broken output pipes. Make the signal-handling runner the terminal-owned process and route external Electron termination through the existing quit lifecycle.
* fix(usage): read Cursor token from modern state.vscdb key via node:sqlite
Settings → Usage showed Cursor as unavailable for a normal Cursor desktop
login. The quota fetcher read the access token from the legacy `cursorAuthStatus`
key, but current Cursor builds store a plain JWT under `cursorAuth/accessToken`
with no such legacy row, so the lookup returned null.
Read the token with built-in node:sqlite instead of shelling out to a `sqlite3`
CLI (absent by default on Windows) — a second, independent cause of the same
"token not found → unavailable" failure. Prefer the modern key, fall back to the
legacy `cursorAuthStatus` blob for older builds, and log read failures at debug
so an unavailable card stays diagnosable.
Fixes#2586
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(usage): cover Cursor sqlite text and blob auth
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
* docs(hub): document per-execution worktree branch names
Hub now materializes `${{ paseo.execution.id }}` inside a daemon
environment's `worktree.newBranch` before it persists or dispatches the
launch, so a reusable branch-off environment can get a stable branch per
execution instead of every trigger contending for one literal name.
The reference states the constraint precisely because it is the part the
YAML cannot show: `newBranch` accepts that one execution-scoped path and
nothing else, and any other expression fails bundle activation with the
authored file and field in the error. Literal branch names stay valid.
* docs(hub): reshape the per-execution worktree branch docs
Split the two pages by the job each reader is doing. The daemon page is a
task guide, so it now carries the worktree YAML and only the two facts
that reader needs to choose a branch name, then links out. The reference
owns the exhaustive contract at the `newBranch` field: a table contrasting
a literal with the template, then the accepted expression scope and what
activation rejects.
Dropped the render-before-dispatch ordering. Readers cannot act on where
Hub materializes the value; the reachable behavior is that a retried or
recovered execution keeps its branch, which is what the table says.
* docs(hub): make the per-execution worktree branch the only guidance
A branch-off Hub environment that aims every execution at one branch name
is not a configuration worth teaching, so the docs no longer present it as
an alternative. Both pages now show `newBranch: trigger-${{ paseo.execution.id }}`
and say what it does, with no literal-branch example and no comparison.
The reference keeps the field's type as a bare syntax fact, along with the
accepted expression, what activation rejects, and the authored field the
failure names.
Stop treating heartbeat silence as proof that a live daemon worker should be destroyed. Crash recovery, explicit lifecycle requests, and supervisor-loss cleanup remain unchanged.
* feat(skills): add Paseo help and provider diagnostics
Give support agents a topology-aware troubleshooting entry point backed by the deployed documentation index, and expose the daemon provider diagnostic through the CLI so agents can inspect the affected host directly. Agent-launching skills now use configured profiles instead of the removed orchestration preferences.
* test(cli): keep provider diagnostics on real boundaries
Remove the internal client mock and preserve its normalization and host-targeting assertions through the existing isolated-daemon coverage.
Hermes in the production Android app does not provide Array.prototype.toSorted. Multi-host project selection and provider cache eviction could therefore crash when they reached deterministic sorting paths.
* feat(app): improve markdown file previews
Keep rendered documents on the chat reading rail, surface YAML front matter as readable metadata, and make shared Markdown links retain the chat accent and hover treatment.
* fix(app): preserve unsafe frontmatter source
* test(app): cover markdown link behavior
Android AsyncStorage's 6 MiB SQLite ceiling was exhausted by per-workspace provider snapshots with no eviction. Raise the native cap to 10 MiB and bound snapshots to 4 MiB with indexed eviction, clearing legacy unindexed data without scanning payloads on every save.
The worker watchdog treated wall-clock time spent in host sleep as evidence that the worker was unresponsive. Count consecutive observed heartbeat misses instead so sleep does not destroy live terminal sessions while genuinely stalled workers still restart after the same active observation window.
* fix(worktrees): honor saved setup configuration
Project Settings writes paseo.json in the source checkout, but existing committed copies in new worktrees previously won silently. Copy the saved project config before lifecycle setup so the command shown to users is the command that runs.
* fix(worktrees): replace config symlinks safely
A selected ref can place a symlink at paseo.json, so copying over that path may overwrite a file outside the worktree. Materialize the saved source config with atomic rename semantics to replace the path entry without following it.
* perf(app): skip transient replica cache writes
The replica cache subscribed to the entire session store, so live stream-head updates forced repeated JSON serialization and browser storage writes even though the head is not persisted. Select the cache's persisted inputs once and schedule writes only when that projection changes.
* test(app): move perf cleanup into fixture
Keep the replica-cache performance scenario as a direct setup, stream, measure, and assert flow while the spec-local Playwright fixture owns deterministic mock-agent cleanup.
* perf(app): remove unused session message state
The legacy message accumulator had no readers but copied its full array for each activity and updated Zustand for every assistant chunk. Keep only the live voice-abort notification and voice transcription behavior.
* test(app): use live session action in task subscription test
* fix(omp): negotiate RPC protocol v2 so large responses don't overflow
OMP caps protocol-v1 single-line RPC frames at 1 MiB. get_available_models
returns the full effective model catalog (hundreds of models), which exceeds
that cap, so OMP replies with an overflow error ("RPC response exceeded the
transport limit") and the OMP provider fails to start sessions.
Negotiate RPC protocol v2 after OMP's startup `ready` frame when it is
supported: v2 lifts the single-frame ceiling to 64 MiB by chunking oversized
frames. Add rpc_chunk reassembly to the shared JSONL transport so chunked
responses are reconstructed before routing, and gate the negotiation on the
ready frame so it is in place before the first request.
Protocol v2 is negotiated only when the peer advertises support in its ready
frame; peers that stay on v1 (e.g. Pi's --mode rpc) are unaffected.
* fix(omp): make protocol startup atomic
---------
Co-authored-by: pi3123 <pi3123@users.noreply.github.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
* feat(cli): add workspace rename command
`paseo workspace rename <workspace-id> <title>` sets a workspace's
user-visible title through the existing workspace.title.set RPC, and
`--reset` clears the override so the name reverts to the branch or
directory. The command reads the descriptor back so the output shows
what a reset reverted to.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(cli): harden workspace rename against read-back and arg failures
Review findings on the rename command:
- A failed workspace list sweep after a successful title change reported
the whole command as failed. The read-back is display-only, so it now
runs outside the mutation's error path and degrades to a null name.
- Only --reset needs the descriptor read back; a set title is already the
resolved display name, so the common path no longer pages the list.
- Dropped the `"code" in error` rethrow. Nothing in that block throws a
CommandError, but DaemonRpcError always carries a `code` property even
when it is undefined, so the guard let those through to render as
UNKNOWN_ERROR with a stack instead of WORKSPACE_RENAME_FAILED.
- Commander 12 allows excess positionals, so an unquoted multi-word title
renamed to its first word and dropped the rest. The command now rejects
excess arguments.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* refactor(cli): report the rename RPC response instead of reading back
Reading the descriptor back to show what --reset reverted to was the only
reason rename needed a paged workspace sweep, and that sweep dragged in
everything around it: a failure path that could report an applied rename
as failed, a nullable name, and a bespoke warning sink to tell the two
null cases apart.
Sibling commands don't do this. `workspace archive`, `agent mode`, and
`schedule update` all report what the RPC returned and stop; the one
command that reads back is `agent update`, which has the same "applied
but reported as failed" bug. So rename now mirrors `workspace archive`.
`--reset` reports `title: null` rather than naming what it reverted to;
`paseo workspace ls` shows the resolved name. This also reverts the
`collectWorkspaces` extraction, whose only second caller was rename.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* feat(agents): surface live task progress
Keep current task state above the composer and turn task snapshots into semantic timeline activity. Normalize provider task protocols while preserving genuine plan flows.
* fix(agents): admit tasks through stream sequencing
* test(agents): expect normalized task status
* fix(server): always create requested worktrees
A slug is a preferred name, not workspace identity. Let the worktree creator suffix occupied paths and branches so duplicate and detached-slug requests still create fresh worktrees.
* fix(server): suffix occupied checkout branches
Fresh creation also applies when checkout targets are already in use. Create a suffixed branch from the requested branch instead of rejecting the request.
* test(server): align occupied branch coverage
* test(server): remove stale worktree reuse expectations
* Search models across all providers from the picker root
* Store agent profiles in daemon config and apply agent config in one RPC
An agent profile is a named bundle of provider, model, mode, thinking
option, feature values and notes, stored host-wide next to terminal
profiles. It carries no system prompt: AgentSessionConfig.systemPrompt is
creation-only, so a profile holding one would apply when starting an agent
and silently do nothing when applied to a running one.
Applying a profile through the four single-field RPCs can fail halfway and
leave an agent matching no profile, so agent.config.apply takes the whole
bundle. Model runs first because a thinking option only means anything
against the model that offers it.
Old daemons parse their persisted config strictly and would drop an
unknown agentProfiles key, making a save appear to succeed and vanish, so
both capabilities are advertised on server_info for the client to gate on.
list_profiles exposes the profiles to orchestrating agents, which read the
notes to pick one and copy its values into create_agent.
* Add the agent profiles settings section
Profiles live behind one module boundary so removing the feature is a
directory plus one mount, which is what favourites never was. The picker
and composer will ask for rows and an apply function rather than learning
what a profile contains.
The edit form follows the form-model pattern instead of the terminal
profile modal's per-field state, because provider selection cascades into
which models, modes, thinking levels and features exist below it.
The section is gated on server_info.features.agentProfiles: an older daemon
drops the key on write, and a save that appears to work and then vanishes
is worse than an unavailable state.
* Document agent profiles and name the four senses of profile
The glossary reserved Profile for HostProfile, terminal profiles owned the
user-facing word, and custom-providers.md used it for provider aliases.
Agent profile is now the term, with all four cross-referenced.
data-model.md never documented terminalProfiles either, so both profile
lists are backfilled together along with the whole-list patch semantics and
why absent and empty differ.
* Pin agent profiles to the model picker and delete favourites
Applying a profile writes its values into the agent controls and is then
forgotten, so rows carry no checkmark and no selected state: after applying,
nothing is selected. Chevron still means navigate deeper, so the absence of
one is what marks a row as an action.
A running agent is its provider's process and agent.config.apply carries no
provider, so the live picker only offers profiles matching the agent's
provider rather than showing rows that cannot do what they say. The draft
path has no such limit and switches provider freely.
Draft feature values go through preferences rather than the feature
callback alone, because the draft feature hook clears local values whenever
the provider changes and a provider-switching profile would lose them.
Favourites are deleted rather than migrated: they were device-local
AsyncStorage keyed by provider and model, while profiles are host-wide
daemon config. ProviderSelectionModelRow.favoriteKey stays as row identity
and moves to its real owner; renaming it is its own commit.
* Cover agent profiles and cross-provider search with E2E specs
Model rows expressed selection only as a checkmark glyph, which assistive
tech cannot see and a spec cannot assert. Rows that draw the indicator now
carry aria-selected; profile rows deliberately carry none, because a
profile is an action rather than a selection. That is what makes "nothing
is marked selected after applying a profile" a real assertion instead of a
tautology, with the materialized model one level down as the control.
The search fixture extends claude rather than mock: provider-config.ts
only allows the six shipped providers as extends targets, so a derived
provider cannot inherit the dev-only mock provider.
* State what applying a config bundle does on partial failure
applyBundle is not a transaction and the earlier commit message implied it
was. A step that rejects aborts the rest and the steps that already applied
stay applied, which a profile omitting modelId can reach: provider setters
validate against the agent's live model, so mode can land while a feature
that model does not support is rejected.
Nothing is hidden by that. Every setter emits agent state before returning,
so what applied is streamed to the client next to the rejected response.
Rolling back would re-invoke the setters that just failed, usually against
a session that is gone, and pre-flight validation would restate each
provider's model-dependent checks where they would drift from the setters
that own them.
What sending the bundle in one request removes is the multi-round-trip
failure class: interruption between hops, and interleaving with another
client. The test already pinned this behaviour; its name now says so.
* feat(agent-profiles): refine profile discovery and appearance
Make profiles discoverable from empty and provider-scoped model pickers, keep global search stable and virtualized, and move profile settings under Agents. Replace emoji identity with a shared Lucide icon and host-color picker, while requiring concrete provider selections so profiles materialize predictably.
* fix(agent-profiles): defer profile swatch styles
Resolving Unistyles proxies at module load can freeze the temporary startup color scheme before the persisted theme is restored. Keep only style-key names at module scope and resolve the selected swatch during render.
Switching the sidebar between Project and Status grouping takes three
clicks today: open the display preferences menu, open Grouping, pick the
mode. It is the only sidebar preference with no fast path.
Typing "group" in the command center now offers a single entry that
switches to the mode you are not in, so it reads "Group by status" while
grouped by project and the reverse once you are in status mode. With
two modes a "cycle grouping" label would not say where you land, and an
entry that reads the same in both states looks like it does nothing.
The entry is query-only, so the default Cmd-K list is unchanged. It does
not clear the focus restore element, because it navigates nowhere and
focus has to return to whatever you were typing in.
The contribution is built by a pure function in root-contributions.ts so
its branches can be unit tested, mirroring workspace-contributions.ts.
The registration subscribes to groupMode with a narrow selector to avoid
re-registering every root action when host filters are reconciled.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Fix spelling and grammar errors found in a full sweep of the
repo's English markdown files.
- CONTRIBUTING.md: "contributing to to it", "accpeted", "wil"
- .github/PULL_REQUEST_TEMPLATE.md: "value provider to" instead
of "value provided to"
- docs/product.md: "vendor-lock in" instead of "vendor lock-in"
- packages/expo-two-way-audio/CONTRIBUTING.md: "prescreptive"
- packages/expo-two-way-audio/README.md: "can bee added"
- skills/paseo-committee/SKILL.md: "considered and reject"
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Development web and Electron builds now show their source branch in the expanded sidebar, including a branch icon and safe truncation. Dev launchers inject the label at build time; production builds remain unchanged.
Theme registration, the appearance picker, persistence, and shortcut cycling drifted as new choices were added. Make one ordered catalog own those paths so Auto and Pure Black participate consistently, while lifting the Pure Black selected-workspace surface above its sidebar.
Expose Automatic and Manual metadata model selection in Host Settings, preserve existing configured fallbacks, and keep structured generation resilient through discovered candidates. Includes Playwright coverage, translations, public documentation, and settings-row alignment polish.
* perf(app): virtualize large diff rendering
Render bounded diff row groups through one vertical list and one horizontal surface. Bound retained list ranges when expansion grows the data set so large diffs paint promptly without sacrificing scrolling behavior.
* refactor(app): normalize working tree diff options
* fix(app): keep wide diff text reachable
* feat(app): render mermaid diagrams in agent chat with pan/zoom
Fenced mermaid blocks in assistant messages now render as diagrams
instead of code blocks.
Web: mermaid renders inline via dynamic import (code-split, loads on
first diagram). Drag to pan, ctrl/cmd+wheel or trackpad pinch to zoom
about the cursor, double-click to reset; plain wheel still scrolls the
chat. Rendered SVGs are cached across virtualization remounts.
iOS/Android: diagrams render in a WebView running a self-contained
generated bundle (build:mermaid-webview, mirrors the native terminal's
xterm pattern — no CDN, mermaid ships in the app). The inline preview
is touch-shielded and height-driven by a bridge message; tapping opens
a fullscreen viewer where the platform WebView owns pinch-zoom.
Streaming source that doesn't parse yet falls back to the highlighted
code block and keeps the last good render; renders are serialized
through one queue with stale-task skipping so token-by-token updates
cost at most one in-flight layout.
Security: mermaid can fetch external resources during layout (image
shapes, CSS url()) before output sanitization runs — see
mermaid-js/mermaid#7645 — so resource-bearing source is rejected
up front (containsUnsafeMermaidSource) on every platform, htmlLabels
stay off, securityLevel is strict, and the mermaid secure list is
extended so directives can't override app theming or themeCSS.
The lockfile diff includes npm 11.17 normalization churn beyond the
mermaid closure; a no-op npm install produces the same rewrite.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(app): serialize native mermaid renders and pin viewer to last good source
Serialize render messages inside the mermaid webview entry so a newer
streamed update can't re-initialize global mermaid state (e.g. flip the
theme) while a prior layout is still running; superseded queued renders
are skipped. Track the last successfully rendered source on the RN side
and open the fullscreen viewer with that, so tapping mid-stream while
the latest chunk is still invalid can't show a blank viewer.
Addresses PR review feedback.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(app): pair native mermaid renders with their source via request id
A "rendered" bridge event was credited to the newest sent source, but an
older render can complete while a newer (possibly invalid) chunk's send
is in flight, mis-crediting the older render's success to the newer
source. Render messages now carry a request id that the entry echoes on
rendered/renderError; the RN side resolves the source by id, so the
fullscreen viewer always opens the exact source that produced the
visible render.
Addresses PR review feedback.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(app): harden mermaid guard and native preview per adversarial review
- Guard bypass (blocker): mermaid accepts quoted and unicode-escaped
object keys, so quoted "img": evaded the resource denylist while
still parsing into a real img property. The guard now decodes u/x
escapes and strips quoting characters before matching, and tests
both raw and normalized source.
- structuredClone polyfill banner in the webview bundle: the app's iOS
floor is 15.1 but structuredClone exists only from 15.4; esbuild
targets don't polyfill runtime APIs.
- Preview height sizes the inner content view so fence padding/borders
no longer clip the reported SVG height, and measurement happens at
the final content width (measuring overlay keeps the fence insets).
- The invisible measuring overlay is hit-test transparent, so an
invalid diagram's fallback code block keeps its copy button and
gestures.
- renderError now prunes all superseded source-map entries, matching
the rendered path, so a long broken stream can't accumulate sources.
- The entry's render chain can no longer be poisoned: the whole task
body is inside try/catch and the chain appends a trailing catch.
- "Mermaid diagram" accessibility label is translated in all locales
(message.mermaidDiagram) on web and native.
- esbuild pinned in packages/app devDependencies so the generated
webview artifact doesn't depend on hoisting layout; </script is
escaped in the generated JS.
Addresses adversarial review round 2.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(app): mermaid source toggle and visible zoom controls
Web: a hover control cluster on the diagram (zoom in/out anchored to
the viewport center, reset view, view source) mirroring the code
block's hover-copy idiom; always visible on compact form factors where
hover doesn't exist. View source swaps to the highlighted code block
(with its own copy button) and a view-diagram button swaps back.
Native: the fullscreen viewer gains a source toggle beside the close
button, swapping the webview for the highlighted source in a
scrollable view.
All control labels are translated in every locale under
message.mermaid.*; the flat mermaidDiagram key from the previous
commit is folded into the same group.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(app): align mermaid control icons with the app's icon vocabulary
Network is the settings Connections icon — reusing it for "view
diagram" gave one glyph two meanings; use Workflow instead. RotateCw is
the app's established reset/reload glyph (RotateCcw had no precedent).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(app): use Scan for mermaid reset-view control
RotateCw is the app's reload glyph (19 uses) — a reset-zoom button
wearing it invites a reload misread. Scan (corner-bracket fit-to-frame)
is unused and carries the intended fit/reset semantics.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(app): remove raw NUL byte from mermaid cache key separator
A literal 0x00 in the template string made git classify the file as
binary, rendering the PR diff for it unreviewable. Use the escaped
sequence instead — identical runtime key, plain-text source.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(app): align mermaid source-view toggle with the copy button
The source view wrapped the code block while the fence margins stayed
inside it, so the floating view-diagram toggle hung in the margin
whitespace above the visible box — misaligned with the code block's
copy button and outside its hover region. Hoist the margins onto the
wrapper (both buttons now share the box edge) and render the toggle in
the copy button's plain style so the pair reads as one control row.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(app): close mermaid review findings
* chore(app): finish Mermaid PR rebase cleanup
* refactor(app): isolate diagram rendering and stabilize streaming
* fix(app): keep Mermaid previews stable while streaming
* fix(nix): update npm dependency hash
* fix(app): preserve streamed rows through completion
---------
Co-authored-by: Damon Meledones <dmeledon@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
* fix(agents): preserve permission notification callbacks
Permission requests consumed the one-shot completion watcher, so callers never heard about later approvals or the final result. Keep the watcher alive across permission checkpoints and include the actionable request payload in each notification.
* fix(agents): notify concurrent permission requests
* fix(notifications): stop pushes after host removal
Push tokens were persisted indefinitely with no relationship to app pairing state. Give registrations a 48-hour renewable lease so offline unpairing converges, and revoke immediately when the host is reachable.
* fix(notifications): make token revocation bounded and durable
* fix(app): restore composer control tooltips
Composite composer triggers dropped tooltip interaction props or the anchor ref, so hover either never opened the tooltip or left it off-screen. Preserve pressable props and compose menu refs at the trigger boundaries.
* fix(app): align mode tooltip typography
* fix(hub): send hostname during daemon enrollment
Pass the local hostname as daemon enrollment metadata so Hub can assign useful default slugs without coupling daemon authority to the human CLI login. Document the rollout dependency on Hub accepting the optional field.
* test(hub): verify enrollment hostname payload
* refactor(storage): remove chat rooms and agent loops
This is the prerequisite cleanup for the storage backend migration. Legacy wire schemas remain temporarily so mixed-version peers can still parse old messages, while daemon callers now use query-shaped AgentStorage methods that can map directly to SQL later.
* test(cli): remove retired chat and loop coverage
* fix(app): preserve dictation audio across transport failures
Keep captured PCM in the existing local segment buffer when a WebSocket send fails. Finish only after server acknowledgements and fail an idle drain so the retry path can resend the complete recording.
* fix(app): dispose dictation stream sender cleanly
* feat(command-center): add workspace file search
Use the existing workspace-aware fuzzy search so file lookup follows the file explorer's ignore and hidden-directory rules. Cmd/Ctrl+P opens a removable Files scope while the normal command center also includes file matches.
* fix(command-center): stabilize file search results
* fix(shortcuts): preserve project picker command
* fix(command-center): keep file results while searching
* feat(command-center): integrate the files scope into the search row
The scope was a 24px pill with 12px text sitting inside the 16px input line, so
it read as a badge dropped into the query rather than the scope of the search.
Make it a leading cell of the search row instead: folder icon, input-sized label,
and a hairline divider before the caret. It stretches to the row's cross size and
bleeds through the header padding with negative margins, so its height is driven
entirely by the input and the panel's own radius clips its corner. Toggling the
scope cannot resize the header, which the new spec pins.
* fix(command-center): restore the files scope as a chip
The previous commit turned the scope into a flush leading cell of the header. Put
the chip back: rounded, bordered, folder icon and 14px label, no divider.
It stretches to the row instead of carrying a height, so it is exactly as tall as
the input's line box and cannot resize the header.
* refactor(shortcuts): regroup and reorder the shortcut help sections
Panels was a junk drawer: the command center, file search, the shortcuts sheet
itself, new terminal, the sidebars, settings, focus mode and theme cycling. The
two shortcuts people reach for most, Cmd+K and Cmd+P, sat inside it below three
other sections. Navigation held both workspace and tab movement.
Sections are now General, Projects & Workspaces, Tabs & Panes, Layout, and Agent
Input. General leads with the command center and file search. Tab movement joins
Tabs & Panes, new terminal joins the tabs it creates, and Layout keeps only the
sidebar and focus toggles.
Row order is now data rather than a side effect of binding-definition order, so
the row someone opens the sheet to find sits at the top of its section. A test
fails when a new shortcut has no place in that order instead of letting it sink
to the bottom unnoticed.
* Keep file search failures in view
* docs(hub): align public docs with v0.3
Bring the public workflow, API, authority, activity, and self-hosting guidance in line with the released Hub contracts. Clarify the public HTTPS boundary required by provider events while keeping Hub and PostgreSQL private.
* docs(hub): clarify daemon logout scope
* docs(hub): restore Compose path and simplify TLS guidance
* docs(hub): link the single-repo-team-bot example
* feat(client): publish the TypeScript SDK surface
Make the high-level client API usable for external integrations and document complete agent, workspace, provider, event, and recipe flows.
* fix(client): correlate provider snapshots by daemon cwd
Return the daemon's canonical snapshot identity so waitForReady can match updates for relative, tilde, and otherwise non-canonical paths without duplicating filesystem rules in the SDK.
* test(server): make snapshot cwd assertion portable
* fix(client): gate canonical provider snapshot waits
Older daemons do not expose the canonical snapshot identity needed to correlate workspace updates safely. Require the advertised capability and fail with an update-host message instead of guessing across workspaces.
* feat(hub): deploy canonical workflow bundles
Keep CLI deployment aligned with Hub's authored bundle boundary so validation and activation receive the same deterministic source files. Public documentation now teaches that boundary as the only configuration format.
* test(hub): assert validation diagnostics directly
* refactor(hub): deepen HTTP client module
* feat(hub): validate named agent configurations
* docs(hub): align Claude mode example
* Render SVG project icons on native and fall back for ICO
React Native's <Image> silently decodes neither SVG nor ICO, so any
project whose discovered icon was one of those formats showed a blank
slot on iOS/Android while web/Electron rendered it fine. Route SVG data
URIs through react-native-svg on native and let ICO fall through to the
initial-letter avatar instead of rendering nothing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Import Buffer explicitly so SVG icons actually decode on device
React Native has no global Buffer and the app installs no global polyfill,
so projectIconSvgXml threw ReferenceError inside its try/catch and returned
null on every SVG — the exact blank-slot bug the previous commit meant to
fix. Vitest runs in Node where Buffer is global, which is why the tests
passed anyway; the new regression test deletes globalThis.Buffer to keep
the import honest.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Prefer renderable icon formats and unwrap PNG-bearing ICOs
favicon.ico ranked first in ICON_PATTERNS, so any repo with a standard
public/ layout served ICO — which native <Image> cannot decode — even when
an SVG or PNG sat in the same directory. Rank SVG and PNG above ICO for
each name, sniff magic bytes instead of trusting the extension (renamed
PNGs shipped as image/x-icon are common), and extract the largest
PNG-encoded frame from real ICO containers so native clients get a
decodable image instead of falling back to the letter avatar.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Render SVG icons with CSS support and fall back on parse failure
SvgXml ignores <style> blocks, which favicon SVGs commonly use, leaving
paths with a default black fill — invisible on dark themes. SvgCss inlines
the styles before rendering. Pass the letter avatar as the fallback so a
malformed SVG degrades to it instead of an empty slot, and fold the
workspace setup dialog's hand-rolled icon cascade into ProjectIconView so
the format handling lives in one place.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Take the platform as an explicit parameter in icon source helpers
The tests previously pinned isNative to true with a module-level vi.mock,
which coupled them to import initialization and left the web pass-through
paths uncovered. Defaulting an explicit `native` parameter to the real
constant keeps call sites unchanged while letting tests exercise both
variants directly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(app): isolate native project icon rendering
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
* fix(app): remove search from project method selection
The fixed method list should not accept text because it can be mistaken for the directory path step that follows.
* fix(app): preserve native project picker keys
Keep hardware-keyboard delivery owned by the Add Project flow even when method selection has no visible text field.
* test(app): update add project overlay expectation
* docs(hub): name the Hub tools in workflow prompts
Every Hub workflow example now tells the agent which tool to call:
hub.reply for the user-facing response, hub.finish_execution to
complete the step and record structured output. Granting a capability
through allow_outputs makes the tool available; it does not make an
agent use it, and an agent that is not told answers in its own
transcript and leaves the step to its idle timeout. Explicit
instructions are what worked against real agents.
Classifier and other intermediate steps name hub.finish_execution
alone, so the docs do not imply every step must reply.
Drops the github.reply allow_outputs blocks from the GitHub examples.
The GitHub provider registers no output capabilities, so that
declaration never materializes a tool; those agents comment through
their scoped GH_TOKEN with gh, which the reference and trigger pages
already said. The read-only answer branch has no network authority for
that, so it only finishes.
Also quotes the "Request: ${{ paseo.prompt }}" prompt block in the
reference, which was invalid YAML as written.
* docs(hub): wrap triggering input in its own prompt block
Every Hub YAML example now ends its prompt with the raw triggering
message wrapped in <user-prompt> tags, as a separate block after the
workflow instructions. The agent can see where your instructions stop
and the request starts, which matters most on the steps that read
external Slack, Discord, and GitHub text.
The first workflow explains the convention in two sentences and points
at Hub security for the controls that actually limit an agent. Tags
label the input; they are not containment, and the docs do not claim
otherwise.
Replaces the earlier quoting fix in the prompt-blocks reference: the
message now lives in a block scalar, so the colon needs no quoting.
* docs: base worktree examples on origin/main
Every branch-off example now passes a remote-tracking ref. Paseo
fetches remote refs in the background, so origin/main is current while
the local main is whatever was last pulled, and base resolution tries
refs/heads first: an unqualified main silently branches off stale
history. The gotcha is explained once, next to the first branch-off
example in the worktrees guide; the other pages just use the ref, and
the Hub environment reference links there.
Also points the safety-gate classifier at the structured-output
section, which shows the output wrapper the finish call takes.
* docs(hub): configure Codex read-only through provider options
Hub examples described a read-only Codex step as mode: read-only. That
Paseo mode preset pairs the read-only sandbox with approval_policy
on-request, so an unattended Hub step stops to ask a question nobody is
there to answer. It is also not one of the modes the Codex provider
advertises.
Every read-only step now uses the provider-native passthrough proven by
the real-provider evidence in #3025: approval_policy never,
sandbox_mode read-only, web_search disabled. The security guide already
used this block; the workflow guide, examples, and reference now agree
with it, and the reference says plainly that a Paseo mode ID is a
separate surface from these options.
mode: full-access is unchanged. It maps to approval_policy never, so it
does not stall an unattended step.
* docs(hub): show a classifier choosing the repository and the model
Both routing jobs were only documented as caller-supplied inputs, so
there was no example of the thing people actually want: one read-only
classifier that reads the request, picks the repository it belongs to
and the model it needs, and hands both to a worker. Replaces the
caller-only model example, which the new one covers through its
override values.
The two axes are not symmetric and the example says so. A model is a
value, so values.selected_model goes straight into agent.model. An
environment cannot come from an expression at all: the compiler accepts
a literal name or paseo.inputs.<name> and nothing else, so each
repository needs its own environment and its own conditional step. One
classifier, exactly one worker.
Corrects the routing section, which listed environment among the fields
an agent output may select. It never could.
Also puts a blank line between adjacent steps and environments in the
deadline and repository-routing examples, and bases the daemon worktree
example on origin/main, which the earlier sweep missed.
* fix(website): keep development requests local
Canonical host redirects were applied to LAN-addressed development traffic, including inspector WebSockets. The redirected socket retained the Vite port and caused the development proxy to terminate when the public host timed out.
* docs(hub): order the workflow scenarios from simple to hard
The Hub pages were arranged by the shape of the configuration schema
rather than by what a reader is trying to do, which showed up three
ways: the overview opened on the storage hierarchy, workflow
configurations were split between workflows.md and a separate examples
page, and the examples that existed started with the hardest one.
workflows.md is now a ladder. One Slack trigger answering in a thread
stays inline as the first workflow, and nine further scenarios sit
under it as collapsible blocks, each a complete configuration adding
one idea to the one above: full access, repeated replies, a GitHub
trigger, two ordered steps, a typed input, two triggers, a classifier
choosing a model, a classifier choosing a repository, and last the
override composed with ??. configuration/examples.md is removed and its
configurations live there now.
Two claims in the old material were wrong and are corrected. Nothing
carries between steps except a declared output, so the planning
scenario passes its plan through output.schema instead of telling the
second agent to follow a plan it cannot see, and a worktree environment
creates a worktree per execution rather than sharing one. GH_TOKEN
authenticates the gh CLI but does not configure git, so the pages no
longer imply a step can push without a commit identity and credential
helper on the daemon host.
The overview drops the organization/connections/daemons/projects tree,
which concepts.md already owns, in favour of the smallest configuration
that does something. Hosting is now a link from the product pages
rather than a section in them.
* docs(hub): make GitHub access an explicit step grant
GitHub authority gets its own page. A step holds a token only when it
declares a github block — connection, repositories, permissions,
duration — and Hub supplies GH_TOKEN plus environment-only git
configuration, revoked when the step ends. No credential is ever
implied by the trigger source.
Rewrites the issue-to-pull-request scenario onto the block, repoints
the trigger, reference, FAQ, and concepts pages, and sweeps the Hub
pages for announcing sentences and compressed paragraphs.
* docs(hub): lighten dense reference prose
Restructure paragraph walls into short paragraphs, lists, and code
blocks: logout behavior in daemons, deploy paths and precedence in
configuration, install limits and dispatch fields in the API page,
required outputs and partial resolution in the reference, and the
policy translation and secrets guidance in security. Fixes two
remaining trigger-derived credential claims in triggers/index.
* fix(nix): update npm dependency hash
* docs(hub): document shared prompts and authority defaults
* fix(server): bound shared-worktree fetch refreshes
* fix(server): preserve buffered git metadata events
* fix(server): cover remote ref selection changes
* fix(server): preserve narrow fetch echo routing
* fix(server): preserve ref updates during refresh
Keep normal refresh requeue semantics for watcher events that arrive while a ref-derived calculation is active, so a second same-ref movement cannot be mistaken for the original fetch echo.
* fix(server): narrow external fetch ref updates
Use authoritative fetch boundaries to separate watcher echoes from outside events, and retain remote ref names so packed-to-loose materialization from other Git clients stays narrow without weakening add/delete fallbacks.
Keep the animated ring phase-locked across hosts and derive its knockout surface from each host's actual interaction state. Align filled status circles at 6px while preserving the larger needs-input alert.
Co-authored-by: Christoph Leiter <christoph@leiter.io>
Explicit inline-code line height creates oversized painted backgrounds when mixed markdown wraps on native. Let native font metrics determine the span height so adjacent lines remain visible.
* fix(desktop): treat unpublished updates as unavailable
GitHub releases can become visible before their platform updater manifests finish publishing. Treat that specific upstream condition as an unavailable update while preserving Paseo reporting for genuine updater failures.
* refactor(desktop): inject updater runtime dependency
Exercise updater event and rejection behavior through a typed fake at the runtime boundary instead of coordinating a hoisted module singleton in tests.
* fix(desktop): preserve lazy updater initialization
* revert(desktop): drop updater runtime refactor
* fix(desktop): restore direct LAN connections
Desktop 0.3.0 routed remote WebSockets through the Electron main process, causing previously working macOS LAN connections to fail with EHOSTUNREACH. Revert custom direct headers and restore the renderer WebSocket path used before the regression.
* fix(app): replace host connections by identity
Keep one connection per stable ID when TLS or password settings change. This preserves the current-main upsert invariant while the custom-header rollback removes the unrelated transport feature.
Mouse drags start and end inside rendered list items, so the clipboard serializer dropped item semantics even when the visible marker was selected. Treat the marker as explicit selection intent while keeping text-only selections unmarked.
* fix(server): keep watcher failures from stalling the daemon
Native watcher teardown can block the Node main thread when an error overlaps subscription lifecycle work. Reduce lifecycle churn, abandon errored subscriptions into bounded polling, recover only after setup settles, and expose error callback frequency in runtime metrics.
* fix(server): keep watcher recovery bounded
Render and search the shortcut chord that actually fires, including rebinding default-only display tokens such as question mark and index wildcards. Unassigned rows show no empty badge, and named-key overrides use the shared display vocabulary.
Allow users to clear and restore shortcut bindings, persist the explicit unassigned state, and keep shortcut displays and actions aligned with the binding that fires. Includes focused unit coverage and desktop renderer Playwright coverage.
* feat(app): add AMOLED and all four Catppuccin themes
Add an AMOLED theme (pure black background) and the four official
Catppuccin flavors (Latte light, Frappé/Macchiato/Mocha dark) to the
appearance picker. Generalize the dark-theme builder to support a light
surface variant, register the new themes with Unistyles, and patch all
registered theme keys when applying font/syntax appearance settings.
* fix(app): localize Catppuccin + AMOLED theme labels across language packs
The initial theme commit pasted the English brand/flavor labels into every
locale. Localize them following each pack's convention: transliterate
Catppuccin and the coffee-flavor names into Arabic/Russian scripts, use
katakana/hanzi equivalents in ja/zh-CN, and standard loanwords in
fr/es/pt-BR. AMOLED is a universal acronym and stays as-is except where
the pack transliterates (Arabic).
* fix(app): make Latte terminal ANSI colors readable
* test(app): cover appearance theme picker options
* refactor(app): scope theme PR to AMOLED
* refactor(app): rename AMOLED theme to Pure Black
* fix(app): refine Pure black appearance
* test(app): wait for sidebar toggle icon
* test(app): remove hover timing race
---------
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
* fix(app): preserve projects across host switches
New workspace entry context retained only a host-local placement, so switching hosts could replace the selected project with the destination's first project. Hydrate that placement against canonical project identity while keeping exact clone placement separate.
* fix(app): preserve manual project choice during hydration
Keep the raw route placement key as stable selection context while hydrating the routed project to its canonical identity.
* fix(server): archive Hub executions by workspace identity
Hub archive previously depended on backing-specific owner state and archived the agent directly, so local and reused workspaces could survive. Resolve the required workspaceId from the execution agent and let the shared workspace archive service own agent, terminal, and backing teardown.
* fix(server): preserve agent archive through workspace teardown
* test(server): keep Hub regression fixtures workspace-scoped
* fix(hub): own executions through fresh workspaces
Hub executions must own a workspace regardless of backing so archival has one lifecycle boundary for agents, terminals, and checkout cleanup. Retain the retired wire selector for parsing compatibility while removing its ownership semantics.
* fix(server): serialize archive metadata writes
* fix(server): preserve archive during reconciliation
Allow desktop metadata controls to shrink so long host and starting-ref labels truncate without crossing the composer rail. Adds a real-browser regression for the combined long-label case.
* fix(app): make session rename reachable in the mobile sessions dropdown
The per-session actions menu in the mobile tab switcher (a gorhom bottom
sheet) opened as an anchored popover, which on native renders a second RN
Modal stacked on the sheet — the fragile nested-overlay case docs/floating-panels.md
warns about. The actions menu (incl. Rename) never appeared on native, so
sessions could not be renamed from the dropdown.
Give the menu compactMode="sheet" like every other mobile menu in the file,
so it stacks as a gorhom sheet on a gorhom sheet (the supported path). Extract
the accessory into workspace-tab-trailing-accessory.tsx and add a reachability
test that fails if the sheet presentation regresses.
* test(app): cover compact session rename via the tab switcher e2e
Adds a Playwright spec that drives the mobile-viewport sessions dropdown -> per-session actions sheet -> Rename, exercising the real gorhom sheet interaction the fix restores (the desktop rename specs only cover the right-click context menu, and the unit test mocks the menu engine).
---------
Co-authored-by: Klaude <klaude@Klaudes-MacBook-Pro.local>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
* fix(app): make the sidebar resize handle usable by touch
The drag handle was a 10px strip at `right: -5`, so half of it sat outside
the sidebar's bounds. Web hit-testing does not clip to the parent box, but
iOS `hitTest:` and Gesture Handler's Android traversal both do, leaving a
~5px target with no visible affordance — hover is the only cue, and hover
never fires on native.
Split the handle by input capability rather than by platform: a fine pointer
keeps the hairline straddling the border, and touch gets a 24x88 grab target
that sits fully inside the sidebar with a permanent grip. The pan gesture now
requires horizontal intent and fails on vertical drift, so a finger on the
grip still scrolls the list behind it.
`useHasFinePointer` replaces the copy of the same media query that lived in
the review surface.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* refactor(app): simplify touch resize handle styles
* fix(app): reveal touch resize grip while pressed
Keep the touch grip out of sidebar content while idle, then expose it for the full resize gesture lifecycle so it remains visible during an active drag.
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
Restore desktop notification sounds by default, add a persisted Play sound setting, and group notification permission and testing in a dedicated Notifications section.
* fix(server): summarise Claude AskUserQuestion permission notifications
Claude's handlePermissionRequest never set title or description on the
AgentPermissionRequest it emits, so buildPermissionDetails fell through to
serialising request.input. For AskUserQuestion that input is the whole question
object, and the push and in-app notifications both showed truncated JSON.
Summarise the first question the way the OMP and Pi providers already do for
their ask_user permissions: title is the question text, description is the
option labels. request.input is untouched, so the permission UI keeps rendering
the full payload.
Closes#2612
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FWY2Xdowxr562GXFdxPhjZ
* test(server): assert question permissions through the emitted request
Greptile flagged that the suite imported a production helper that was only
exported for the test, which leaves an unnecessary public export behind and
couples the tests to implementation structure.
The helper is module-private again, and every case now asserts on the
permission request the session emits and the notification body built from it.
Added a case pinning request.input so the permission UI payload stays covered,
and one for a non-question tool.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FWY2Xdowxr562GXFdxPhjZ
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Refresh expired Kimi usage tokens
* Test Kimi usage token refresh
* Harden Kimi usage token refresh
Follow-up review of the refresh path added in f6ce001a.
Credential reads no longer depend on the shape of fields this provider
only writes back: declaring expires_at and friends as required numbers
meant a null or stringified value threw away the access token sitting
next to them, turning a working Kimi quota bar into "unavailable" with
no way back. Those fields are nullish and coerced now.
Refreshed tokens are merged into a re-read of the credential file rather
than the copy the request started with, so a write by Kimi Code during
the refresh round-trip survives, and a file deleted in that window stays
deleted instead of being resurrected with fresh credentials. A failed
write logs at warn: Kimi rotates the refresh token, so losing that write
silently logs the user out of Kimi Code.
Only 401 triggers a refresh. A 403 means authenticated but not entitled,
which no refresh fixes, and refreshing anyway rotated the user's refresh
token on every poll.
Also: floor expires_at to whole seconds, indent the file the way Kimi
Code does, derive the temp file name from the real basename, and split
out fetchUsageResponse, which is what puts fetchUsage back under the
complexity limit — the original passed neither lint nor the formatter.
* Keep CLI-rotated Kimi credentials during refresh
The Kimi CLI shares the credential file the quota fetcher writes. A refresh
read the file, merged its new tokens, and renamed over it, so a rotation that
landed while the refresh was in flight was replaced with stale tokens and the
CLI's session broke until credentials were recreated.
Thread the refresh token that was actually spent into the persistence step and
skip the write when the file no longer carries it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(kimi): normalize additional usage windows
* fix(kimi): preserve custom auth while parsing limit windows
* test(kimi): cover additional usage windows
* test(kimi): move usage-window tests into service.test.ts
Every other quota provider is tested in service.test.ts; kimi.test.ts
was the only provider with a standalone collocated test file.
* fix(kimi): resolve reasoning-effort options per model, not per catalog probe
deriveModelDefinitionsFromACP derived thinkingOptions once from whichever
model happened to be active in the one-off catalog probe session, then
copied that same array onto every model. Kimi's ACP adapter only reports
thinking levels for the currently selected model, so K2.7/K2.7-coding-fast
(boolean on/off) and K3 (off/low/medium/high) ended up with identical,
often-wrong options depending on which model the probe defaulted to.
fetchCatalog now reuses the single probe session to switch through each
candidate model and read back its real thought_level options, falling
back to the default options if an individual model's probe fails. Skips
entirely when there's one model or no thinking picker, so providers
without per-model effort levels pay no extra round trips.
* refactor(kimi): merge kimi-usage.ts back into kimi.ts
kimi.ts was the only quota provider split across two files; every
other provider (including claude.ts at 503 lines, larger than the
merged Kimi file) stays in one. Also fixes a nested-ternary lint
error surfaced by the merge.
* fix(kimi): re-validate credentials immediately before the rename
Greptile flagged that saveRefreshedCredentials read the credential file,
checked its refresh token against a stale snapshot, then did async
writeFile work before an unconditional rename — leaving a window where
the Kimi CLI could rotate the file after the check and have its newer
tokens clobbered by our stale merge. The refresh-token-only comparison
also missed a CLI access-token rotation that kept the same refresh token.
Re-read the file immediately before the rename and compare both tokens
against the snapshot the merge was built from, discarding the merge if
anything changed. Narrows the check-then-write gap to the smallest
window possible without cross-process file locking.
* refactor(acp): make per-model catalog probing an injectable resolver
Catalog discovery in ACPAgentClient.fetchCatalog ran sequential
setSessionConfigOption model switches for every multi-model ACP with a
thought_level picker, so a slow or nonconforming agent could stall any
provider's catalog probe under the shared 60s timeout.
Replace the built-in resolvePerModelThinkingOptions with an optional
ACPCatalogModelResolver injected through ACPAgentClientOptions (threaded
through GenericACPAgentClient like the existing transformer/parser deps).
The base client ships no resolver, so catalog discovery derives models
from the initial session response and never switches models. Export the
config-option helpers a resolver needs.
The base regression test pins that a plain ACPAgentClient with multiple
models and a thinking picker never calls setSessionConfigOption.
* feat(kimi): resolve per-model thinking options via KimiACPAgentClient
Kimi exposes different thinking-effort levels per model but only reports
the currently selected model's levels, so the catalog probe must switch
through each model to read them back. Move that logic off the shared ACP
adapter and onto a Kimi-specific client.
Add resolveKimiCatalogModels (the sequential setSessionConfigOption probe,
fallback, and warning) and KimiACPAgentClient, which injects it via the
new catalogModelResolver dependency. Select it for providerId "kimi" in
the registry alongside cursor/kiro/traecli, so only Kimi pays the extra
round trips; every other ACP provider skips model switching entirely.
Tests cover the four probe behaviors on KimiACPAgentClient and the
registry dispatch to it.
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
The root error fallback was one unbounded scroller, so on a phone the title
rendered under the status bar and a long stack trace pushed Retry off the
bottom of the screen — the one action that recovers the app was unreachable
on exactly the errors that need it most.
The screen is now a fixed shell: a safe-area message block, an independently
scrolling details region, and a Retry that stays put. `SafeAreaProvider` moved
above the boundary in the root tree because the fallback renders when the app
tree below it has already failed, and insets have to be available there.
The hand-rolled Pressable and bordered message box are replaced by the shared
`<Button>` and `<ScrollableCodeSurface>`, which gives the trace a monospace
face and horizontal scrolling instead of wrapping mid-token. On desktop the
trace is capped and the action sits with the message; a button pinned to the
bottom of a tall window reads as detached from the content it belongs to.
Dropped the "Something went wrong" kicker — it restated the title one line
above it.
Clicking a file path an agent mentioned failed with "No file found"
whenever git ignored the file, which covers anything written under a
scratch directory such as .dev/.
Backticked paths do not open directly. They resolve through the
directory-suggestions RPC, which the daemon runs with gitignore
filtering on for every workspace search. findExactEntry resolved the
path, confirmed it stayed inside the workspace root, and then discarded
it on the ignore check.
Ignore filtering belongs to discovery. It keeps build output out of the
composer's @-mention picker and out of the directory chooser, and both
of those keep it. It does not belong to retrieval of a path the caller
named exactly, where containment inside the root is the only question
left. That check is now dropped from the exact-path branch alone.
This does not widen what is readable. The file-read path applies no
ignore filtering at all; resolveScopedPath checks only root containment,
before and after resolving symlinks. Every file this surfaces was
already readable through the file explorer.
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
* fix(codex): omit disabled skills from slash commands
Codex skills/list returns disabled skills with enabled:false. Filter
them out in loadSkills so Paseo matches Codex CLI/TUI and no longer
surfaces stale slash entries for skills the user disabled.
Fixes#2617
* fix(codex): preserve empty skill discovery
---------
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
* feat(providers): add native options and exact MCP grants
Keep provider policy in provider-owned schemas so unattended Hub runs can grant only requested tools from injected MCP servers. Preserve the native resolved sandbox policy when a workflow override is present, including configured writable roots.
* test(hub): verify real provider tool policy
* test(app): avoid pinning transient restart status
* feat(cli): add durable Hub login
Keep organization-scoped human credentials separate from daemon relationship authority. Connect now exchanges explicit or stored CLI authority for a one-time enrollment token, while logout removes human access without implicitly disconnecting automation.
* fix(cli): require TLS for remote Hub credentials
Durable Hub credentials are bearer secrets, so remote origins must not receive them over cleartext HTTP. Keep literal loopback HTTP available for local Hub development.
* fix(cli): make Hub destinations explicit
Resolve optional Hub origins through one shared precedence policy so active logins remain the default context and credentials stay origin-bound. Report the selected destination before human-facing work, keep structured output auditable, and preserve login authority until requested daemon disconnection succeeds.
Make the release document the single source of truth. ACP drift is explicit opt-in, release preparation uses one remote batch, and a heartbeat owns completion through published artifacts and store submissions. Preserve one changelog entry per beta and collapse the series on promotion.
History was a date-grouped list you scrolled. Finding a session from last
week meant eyeballing every row. This adds a search field to the existing
filter rail, next to the host picker.
Search runs on the daemon, not the client. The daemon already holds every
persisted agent in memory, so it ranks the whole history and the client
never has to warn that it only looked at the pages it had loaded.
A searched response is one ranked page with no cursor. The global top K is
always contained in the union of each host's own top K, so merging per-host
pages and slicing back to the limit is the exact global answer — no k-way
merge, and no offset into a ranking that is recomputed per request, which
would duplicate and skip rows as history changes underneath it. Past result
200 of a relevance order the answer is a narrower query, so the footer says
that instead of offering a "Load more" that cannot deliver.
Matching is the app's existing picker matcher, moved to the protocol package
so app and daemon share one implementation instead of drifting into two
"why didn't this show up?" rule sets. Typo tolerance is opt-in, so the
comboboxes keep exact narrowing; the budget is per token, and four-character
tokens get transpositions only, because at that length a free substitution
turns "main" into "mail", "maid" and "rain".
Ranked rows carry the spans they matched on, so results can mark them. The
subsequence and typo tiers match characters the eye would never find
unaided, and an unexplained row reads as a bug. The ranker computes the
spans anyway; sending them keeps the client from deriving a second opinion
that could disagree with the ranking it is explaining.
Hosts that fail or are offline are now named above the list. Without that,
"No sessions match" is a claim the app has no basis for.
Gated on server_info.features.agentHistorySearch, checked once against the
connected hosts. Reachability is reported by the banner instead, so one
offline host in the registry no longer hides the field.
* fix(pi): stream assistant deltas when pi omits the cumulative message
pi 0.84.0 removed the cumulative `message` snapshot from JSON/RPC
`message_update` events, so `handleMessageUpdate` dereferenced
`undefined` and took down the whole daemon on the first assistant delta.
Every prompt to a pi agent crash-looped the worker.
`message_update` carries an `assistantMessageEvent`, which pi only emits
for assistant messages, so a missing snapshot is treated as an assistant
update. `message_start` still carries the message, so the response id
keeps flowing through `activeAssistantMessageId`.
Fixes#2940Fixes#2946Fixes#2950
* fix(pi): give the message_update COMPAT tag a version and removal date
Follows docs/protocol-compatibility.md: every shim tag needs a name, a
version, and a removal condition or date so `rg "COMPAT\("` stays a
schedulable cleanup backlog.
---------
Co-authored-by: dundunHa <dundunhade@gmail.com>
The app and host version lines ran as one sentence, so the versions ragged
with the length of each name and were hard to compare. Menu hints now take an
optional trailing cell and lay out as key/value across the row.
Renaming the string to a plain name drops its interpolation, so every locale
carries the same untranslated product name it already had.
MenuHint renders in one place today (this menu), so the row layout in the
shared component changes nothing else.
The row only renders under its project header, so stepping it in reads as
part of that project rather than as a peer of it. Extra padding rather than
margin keeps the hover and pressed fills on the same box as every other
sidebar row.
* fix(server): make workspace archive self-healing
Background setup could keep writing after its workspace record was archived, causing disk cleanup to fail and the initiating client to restore a local ghost. Track setup per daemon so archive can stop it, and let repeated archive requests finish residual cleanup after the record has committed.
* fix(server): await setup process termination
Archive must not proceed from setup cancellation until the process-tree terminator has completed. Join that promise to the streamed command's existing finish path so child close cannot release workspace cleanup early.
Hidden optional rows reserved space in the middle of the compact form. Move those spacers below the visible controls so worktree options push the launch selector down without separating it from the other selectors.
Use the standard browser WebSocket constructor without the Node/React Native options object, while retaining the options-capable factory for supported runtimes and the desktop bridge.
Selections contained inside formatting copy only their visible content. Preserve Markdown syntax when the range includes or crosses the semantic element, while retaining list and table structure needed to serialize child boundaries.
Flattened child list lines were reparented inside the parent item line, so rich paste targets split markers from their content. Keep every flattened line as a sibling and cover cross-structure selection plus inline-code double-click copying.
* feat(cli): add Hub configuration deploy
Deploy configuration bytes directly to an explicitly targeted Hub project using only caller-supplied origin and organization API key authority.
* refactor(cli): name Hub deploy contracts
* docs(hub): complete deploy CLI guidance
* docs(hub): link deployed API references
* feat(cli): bundle referenced Hub partials
Send exact referenced prompt-partial content alongside YAML so Hub can resolve local includes while inline-only configurations keep the existing request shape.
* fix(cli): match Hub YAML size semantics
Apply the finalized Hub request limit to decoded YAML length and cover multibyte configuration payloads without changing the wire body.
Intrinsic image dimensions can arrive after the timeline has painted, outside the React segment-change path. Let the browser preserve the visible content anchor during that resize instead of exposing a frame before the deferred bottom correction.
* feat(app): launch a terminal from the New workspace screen
New workspace only ever started a chat agent. Empty workspaces existed but
were reachable only by submitting an empty composer, which nothing advertised,
and terminals could not be started from this screen at all. People concluded
Paseo did not support terminals.
Adds a launch chip to the meta row, shaped from the same ComboboxTrigger as
its neighbours, that chooses between Chat and a terminal. Terminal profiles
mark where a typed prompt goes with a {{{prompt}}} sentinel in their command
or args, substituted client-side before create_terminal_request, so the daemon
learns nothing new and the protocol is untouched. A profile without the
sentinel takes no input: the composer goes read-only and shows the exact
command, so there is nothing to type wrong.
Prompt forms come from each CLI's own --help. claude, codex, and pi take a
trailing positional; opencode's positional is the project directory, so it
uses --prompt=. Profiles predating the sentinel adopt the right form on read,
keyed on the command's base name rather than the profile id, because profiles
created in settings get generated ids.
* fix(app): stop existing launchers passing the prompt sentinel literally
Adoption made every caller of resolveTerminalProfiles see sentinel-bearing
args, but only New workspace substituted them. Pinned targets and the
workspace terminal menu forwarded profile.args straight through, so launching
Claude from an existing workspace ran `claude "{{{prompt}}}"` with that text as
the prompt.
Every launcher now goes through resolveTerminalProfileLaunch. Surfaces with
nowhere to type pass an empty prompt, which drops the prompt-only arg and
gives back the bare command they had before.
Also from the same review:
- Adoption skipped when args lead with a subcommand. `opencode run` takes a
positional message rather than --prompt, and `codex login` takes no prompt at
all. Only the first arg is checked, so `pi --thinking high` still adopts.
- Settings edits the persisted list again, not the adopted view. It writes the
whole list back on any change, so resolving first baked read-time adoption
into the user's config the moment they reordered a row.
- A hidden draft no longer names a workspace. Text typed for a prompt-taking
profile stayed in state after switching to a read-only one and still seeded
the generated title and branch.
- Windows .cmd escaping now has a round-trip test: a real cmd.exe runs a real
.cmd shim that echoes its argv, covering metacharacters, an injection
attempt, and newlines. The existing tests only asserted the command line we
generate.
- Fixed an ENOTEMPTY flake in the terminal suite. kill() only signals, so the
shell was still writing into $ZDOTDIR while its directory was being removed.
Cleanup waits for exit instead.
* fix(app): keep the host and isolation accessible names stable
Wiring the new tooltips also replaced these two accessible names with the
tooltip copy, which broke `sidebar-project-grouping` at the host picker: its
selector is `getByRole("button", { name: "Host", exact: true })` and the button
had become "Choose the host". It timed out on both attempts in CI.
An accessible name identifies a control; a tooltip explains it. Conflating them
renamed a control that other surfaces address by name. The names go back to
what they were and the tooltips keep the "Choose the ..." copy.
* test(server): stop temp-dir teardown failing unrelated terminal tests
The ENOTEMPTY flake survived both earlier attempts. Waiting for the session to
exit shrank the window; retrying the remove did not close it either. The writes
come from processes zsh forks on the way out, which outlive the pty and answer
to nothing the test holds.
Teardown is now best-effort and logs what it left behind. A temp directory we
cannot remove is not a defect in the terminal code under test, and failing on it
reports the race against whichever assertion ran last, which is exactly how this
surfaced: as a flake in an unrelated zsh title test. 10 consecutive runs green.
Also replaces a cast in the Windows .cmd helper with a real narrow, so a change
to ResolvedTerminalCommand fails loudly there. Test files are excluded from
typecheck (tsconfig.server.json), so a cast in one is never checked at all.
* fix(app): keep terminal launch policy in its owning workflows
Profile adoption now parses known global option values before deciding whether argv already contains a positional. Workspace naming is derived inside the terminal workflow so hidden launch-only drafts cannot affect branch or title generation.
* fix(server): preserve cmd shim arguments through both parses
npm command shims forward %*, so metacharacters lose one escape layer before the target process receives argv. Exercise the production node-pty string command line against a forwarding shim, and only tolerate the known ENOTEMPTY teardown race.
* fix(server): stabilize Windows subprocess tests
* test(server): isolate Windows argv recorder output
* test(server): allow native watcher integration to settle
The Claude model manifest declares fast-mode support per model, and the
`claude-opus-5` entry was added without `supportsFastMode`. Claude Code
supports fast mode on Opus 5, so the composer and Command Center hid a
toggle the model actually has.
Set the flag. Everything downstream already keys off it:
`claudeManifestModelSupportsFastMode` gates `buildClaudeFeatures`, which
feeds `listFeatures`, the session `features` getter, `setFeature`, and
the `setModel` auto-clear.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* docs(hub): document required output contracts
* docs(hub): link faq to output contract
* docs(hub): document materializable required outputs
Explain that required declarations resolve through registered capabilities, failed deliveries can be retried, and command-line repository comments use the scoped credential path.
Hub configuration resolves a normalized friendly slug to an immutable daemon ID. Document that model consistently so enrollment terminology matches hub.yml.
* fix(terminal): clear activity when turns are interrupted
Some terminal agents do not emit a stop lifecycle hook after a user interrupt. Treat exact interrupt key frames as cancellation at the terminal input boundary so running state cannot stick, and clear rather than finish to avoid completion attention.
* test(terminal): use platform shell in interruption tests
Let the terminal test fixture select the platform default shell so the interruption cases reach their assertions on Windows.
* feat(server): show provider workflows in subagent track
Normalize workflow tasks into the provider-subagent contract so shared clients need no workflow awareness. Persisted nonterminal runs replay as failed because their original runtime can no longer deliver a terminal update.
* fix(server): preserve workflow replay and tool cards
* fix(server): suppress workflow notification cards
* fix(server): replay workflow child timelines
* fix(server): fail tasks when Claude exits
* fix(server): preserve workflow replay parity
* fix(server): fail tasks on Claude restart
* fix(server): hide workflow child prompts on replay
* fix: terminalize workflows on session reload
* fix(server): own workflow notification routing
* feat(server): show workflow results in detail pane
* fix(server): preserve unrestored task notifications
* fix(server): normalize replay task notifications
* fix(app): debounce reconnect status toast
Keep reconnect transport immediate while delaying the visible status toast so brief outages stay quiet. Scope the toast to the active retained panel, keep its copy and status dot consistent across locales, and add a manual real relay deployment journey that verifies streaming recovery without user action.
* fix(app): isolate manual relay deployment check
Keep the deployment journey out of the provider-wide real test project so it runs only when its local relay prerequisites are explicitly requested. Also keep the relay teardown active when packaged daemon startup or later cleanup fails.
* fix(app): preserve reconnect toast across tabs
Keep reconnect presentation timing at the host level so switching between retained panels does not restart the debounce or flash the toast. Add a browser regression covering a visible outage across retained tab switches.
* fix(app): anchor reconnect debounce to host status
Record host connection status transitions independently of mounted agent panels and preserve the outage start across reconnecting states. Use that host-level timestamp when a visible agent panel appears, with runtime coverage for the transition lifecycle.
* fix(app): reset reconnect toast state per outage
* test(app): build relay journey prerequisites
* fix(app): keep line breaks and indentation in copied code
#2930 strips the pre/code tags from a partly selected code block, which
is right for the Markdown syntax but leaves the code itself going
through Turndown as prose. Turndown collapses whitespace, so two
selected lines arrive as one, the indentation is gone, and
Markdown-significant characters come back escaped.
Divert a partial code selection before that: copy the selected text
verbatim. Fully selected regions are untouched and still produce a
fence, which is what hasSelectedAllContents was already deciding.
The html half needs pre for anything multi-line -- HTML collapses
newlines too, so a rich paste target would lose the lines the same way.
A single line stays undecorated, matching the rule that a partial
selection does not carry its container's formatting.
Two trailing-newline fixes ride along, since both make a paste run code
nobody asked to run. Newlines between rendered lines are their own text
nodes, so a selection that overshoots the end of a line carries one;
strip it. The block's Copy button was handed the raw fence body, which
ends in a newline; hand it the rendered text instead.
* fix(app): strip every trailing blank line from copied code
The Copy button stripped one trailing newline, so a fence whose author
left a blank line before the closing delimiter still put an executable
newline on the clipboard. Pasting that into a terminal runs the last
line, which is the thing the strip existed to prevent.
Both copy paths now share one regex in markup.ts and remove every
trailing break, plus any indentation that followed the last one.
Reported by Greptile on #2935.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(app): infer code copy format from selection boundary
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
Retained tabs stay mounted under display:none while inactive, which
reports a zero-width onLayout. ControlledAgentControls fed that
straight into its density resolver, so a hidden tab's toolbar
collapsed to its tightest density and painted one frame collapsed
before re-expanding when the tab became visible again.
Extract the zero-width guard that useContainerWidthBelow already had
into a shared readMeasuredWidth helper, and use it in the composer
toolbar's own layout handler so a display:none measurement can never
drive its density.
Also bump the chat outline rail's MIN_PANEL_WIDTH breakpoint (818 ->
918) so the rail disappears earlier under a narrower panel, and widen
the chat-outline e2e spec's viewport so its panel keeps healthy margin
over the new threshold.
* fix(app): respect assistant copy selection boundaries
Selection serialization reintroduced semantic ancestors even when the user selected only their inner text, causing list markers and code delimiters to expand beyond the visible range. Preserve semantics only for complete selected nodes, and keep rich list payloads compatible with text composers that prefer HTML.
* fix(app): preserve loose list copy breaks
* feat(opencode): fill subagent descriptor with task, type, model, and tokens
OpenCode subagent rows rendered with no information: the initial upsert
carried only the child session title and status, so the track showed a
bare title with an empty second line and the pane tab fell back to
"Subagent".
Fill the same provider-agnostic descriptor fields Claude populates since
#2498, from data OpenCode already emits:
- description <- the task tool call's description input (fallback: the
child session title), so the task names the row
- title <- subagent_type from the task call, else the session's agent
- toolCallId <- the parent task tool call id
- subtitle <- "agent · model · Variant · Nk tokens", folded from the
child's own session record and assistant frames, deduped per child
Facts are never inherited from the parent; absent facts render as
nothing. Presentation upserts omit status so a late subtitle update
cannot revert a finished child (the store's sticky contract). Both
task-link orderings are handled, and the hydration path derives the
same facts for historical children so restored sessions match live.
Server-side only; no protocol or app changes.
* fix(opencode): emit subagent type as descriptor title from assistant frames
* feat(app): show subagent info line in the provider subagent pane
* polish: address subagent presentation review nits
* fix(opencode): link subagents from task metadata
Current OpenCode task results identify their child session in tool metadata rather than the legacy textual output. Preserve that boundary field so parallel task calls retain the correct description, type, and tool-call association.
---------
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
CSS accepts transform-origin keyword pairs in either order, but React
Native parses them positionally (x first). The vertical-first string
crashed anchored menu popovers on native wide form factors with
"Transform-origin left can only be used for x-position".
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(server): report Claude runtime death instead of sitting idle
When the Claude Code CLI process dies on its own — crash, OOM kill, host
suspend — nothing in the daemon observes it unless a turn happens to be in
flight. The query pump only reports a failure while it is draining a turn, so
an idle agent's stream just ends: the agent stays `idle`, indistinguishable
from a healthy resumable one.
Everything the agent parked inside that process dies with it. Background Bash
shells, `Monitor` watches and workflows all live in the CLI process, so the
completion notification that would have woken the agent back up never arrives.
The work is silently gone and the user finds out much later, if at all.
The stale handles are never dropped either, so writes keep going to a dead
transport for as long as the daemon lives (#2472).
Watch the spawned child at the single seam it is already handed over
(`onChildProcess`). When it exits between turns, drop the dead query/input
handles so the next write spawns a fresh process, and emit `turn_failed` —
the manager already routes a non-foreground `turn_failed` to `error` plus a
timeline entry.
A death mid-turn is left alone. The pump already waits for stderr to flush and
reports the real cause; reporting here first would replace that with a bare
exit code.
Intentional teardown stays quiet. All three sites that retire a process detach
the child handle first: `close()` sets `closed`, and `ensureQuery()` and
`handleMissingResumedConversation()` null `childProcess` before ending stdin.
Fixes#2472
* fix(server): tree-kill the retired process on missing-resume recovery
The missing-resumed-conversation path ends the child's stdin but never
tree-killed it, so MCP children of the retired claude process could outlive
it as orphans. Every other teardown path already does this; do it here too.
Reported by Greptile on #2910.
* fix(desktop): preserve browser tabs across focus changes
Electron can replace a guest WebContents when a webview is reparented, which reloads the page and can race automation against a destroyed target. Keep each browser guest on one permanent paintable surface and make user and agent resizing share one canonical viewport.
* fix(desktop): recover browser surfaces after lifecycle changes
* fix(desktop): preserve resident browser lifecycle state
* fix(desktop): seed inactive browser viewport
The running dot no longer animates, and the machinery behind it is gone
rather than switched off: the shared refcounted clock, the theme-scheme prop
mapping, and the extra wave color all existed only to drive it.
Four affordances were tried and each failed for its own reason, which is
worth writing down before someone reaches for one of them again. Fading the
dot blends it toward the row's surface rather than toward black, so the
bottom of every cycle was a washed-out blue that read as the success dot one
row up. Dropping chroma holds brightness but turns the low point grey.
Raising chroma has nowhere to go: blue at these lightnesses is already within
11% of the sRGB gamut edge, so the only real headroom is at the hue-250 cusp,
which costs lightness. An expanding ring solved the color problem outright —
motion cannot be confused with another hue — but stayed too subtle at a size
that suits an 8pt dot sitting beside a title.
If the ring is revisited: the project badge shell clips its contents to 12pt,
which is what made the first attempt invisible on exactly the rows most
likely to be on screen. The clip is not what makes the shell read as a
knockout; its fill is.
* fix(server): finish subagents the parent transcript can never end
A skill-spawned subagent — a /code-review run, for example — has no Task
tool_use for the parent to record, so its sidecar carries no toolUseId
and its id appears nowhere in the parent transcript. Replay resolved no
link, emitted no status observation, and the store defaulted the
descriptor to running. The subagents track then reported a running child
on every reopen, days after it had finished, and the archive dialog
offered to stop it.
Paseo only records an outcome for a child whose Task call it parsed out
of the parent, so a child with no Task call is one no reader can finish.
Resolve identity and status together and mark those completed. A child
that does have a Task call but no result yet is the opposite case — the
parent was interrupted mid-Task — and keeps running.
Keying on "no Task call for this id" rather than "no toolUseId" also
covers legacy grandchildren. Across 554 recorded subagent transcripts,
5 stuck rows had no toolUseId and 8 more named a Task call made inside a
sibling's session; none of the 13 has a recoverable tool_result in the
parent.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(server): admit only declared Claude subagents on replay
* test(server): cover Claude workflow subagent accumulation
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
* Release the audio session when voice capture and playback go idle
Opening Paseo permanently paused music playing in other apps. Once the
mic had been used a single time, every subsequent foreground of the app
killed the user's audio again until the app was force-quit.
iOS AudioEngine.setupAudioSession() takes .playAndRecord/.voiceChat — a
non-mixing category — and calls setActive(true), but the only
setActive(false) was reachable from tearDown(). Nothing handed the
session back after a normal dictation turn, so iOS re-asserted the last
category the app set on every foreground. The iOS module had no
app-lifecycle handling at all, while Android already had a lifecycle
listener; that asymmetry is what made the platforms diverge. Android had
the same leak in a different shape: AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE
was requested per recording and never abandoned.
Add releaseAudioSession() to both platforms and call it once capture
stops and the playback queue drains. iOS pauses the engine, calls
setActive(false, .notifyOthersOnDeactivation) — this is what tells the
other app to resume — then drops to .ambient/.mixWithOthers so any
implicit reactivation mixes instead of interrupting. The session is
re-acquired lazily on toggleRecording(true)/playPCMData, so voice keeps
working; resumeRecordingAndPlayer() re-activates before restarting the
engine, which keeps post-interruption resume intact. Releasing is
guarded on isRecording/isPlaying, and checkEngineIsRunning() now refuses
to start the engine while the session is released, since starting it
would silently re-take the session.
The JS wrapper probes the native module before calling, because a JS
bundle shipped over EAS Update can land on a binary predating this
function.
* Correct the iOS signing diagnosis in mobile-testing docs
The "No Accounts" error does not mean no Apple ID is configured. The
account can be registered in Xcode while its Xcode-Token keychain item is
missing, which Xcode reports as No Accounts and then compounds by falling
back to a stale cached profile — surfacing as a certificate mismatch that
sends you chasing the wrong thing.
Document the DVTDeveloperAccountManager log line that names the real
cause, the two commands that check what is actually configured, and the
build-unsigned-then-sign-manually stopgap.
* Reacquire Android audio focus when playback restarts
releaseAudioSession() abandons the AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE
request and pauses the AudioTrack once capture and playback go idle, but
nothing took either back. The next playback turn therefore ran with no
focus held, so the assistant's response competed with whatever the user
was listening to instead of interrupting it.
Add acquireAudioSessionIfNeeded(), the mirror of the iOS
activateAudioSessionIfNeeded() already called from playPCMData, and use
it from both playPCMData() and resumePlayback(). A null audioFocusRequest
is what "we released" looks like, so it doubles as the guard against
re-requesting focus mid-turn.
Reported by Greptile on #2866.
* Make Android playback focus reacquisition non-fatal
The previous commit dropped PCM data when focus reacquisition did not
return AUDIOFOCUS_REQUEST_GRANTED. That is worse than playing unfocused:
playPCMData is fire-and-forget from JS, and playAudio() settles on a
duration timer rather than a native ack, so the runtime acknowledged and
discarded a chunk that was never queued.
It also broke delayed focus. The request is built with
setAcceptsDelayedFocusGain(true), so AUDIOFOCUS_REQUEST_DELAYED is a
normal outcome, but routing it through handleAudioFocusBlocked() abandoned
the pending request and the later grant could never arrive.
Request focus, then play regardless of the result, and leave the request
outstanding so a delayed grant still lands via the listener.
Reported by Greptile on #2866.
* Track scheduled buffers so iOS actually releases after playback
releaseAudioSession() guarded on !speechPlayer.isPlaying, but
AVAudioPlayerNode.isPlaying stays true from play() until an explicit
stop()/pause() — draining every scheduled buffer does not clear it. The
normal completion path is processQueue() -> releaseSessionIfIdle(), and
stopPlayback() is only reached from the explicit stop() API, so after the
first spoken response the guard blocked every release and the session
stayed active. The music-pausing bug returned in full for anyone using
voice responses.
Count buffers that are scheduled but not yet rendered, decrementing from
the .dataPlayedBack completion handler, and guard on that instead. The
handler runs on an AVAudioEngine-internal thread, so the count is behind a
lock. Both stop() paths reset it, since stop() discards what is scheduled
and a stale count would block every later release.
Found by Codex review on #2866.
* Stop re-taking Android audio focus on every foreground
ExpoTwoWayAudioLifeCycleListener calls resumeRecordingAndPlayer() from
onResume, and that requested AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE before
looking at isRecordingBeforePause. Foregrounding the app while nothing was
recording therefore took exclusive focus back and re-paused the user's
music — the same "every foreground kills my music" symptom this change set
exists to remove, just reached through the Android lifecycle instead of the
iOS category.
Return early unless capture or playback was actually live when we
backgrounded.
Found by Codex review on #2866.
* Add Korean UI localization
Refreshes the Korean translation from #1544 against the current client locale surface and adds cross-platform locale detection coverage.\n\nCo-authored-by: JinHyeok Jeong <150323322+Kesta-bos@users.noreply.github.com>
* fix(i18n): preserve reviewed Korean status labels
Restore the wording corrections contributed in the original Korean localization work and lock them with exact-copy assertions.
Co-authored-by: JinHyeok Jeong <150323322+Kesta-bos@users.noreply.github.com>
---------
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
Co-authored-by: JinHyeok Jeong <150323322+Kesta-bos@users.noreply.github.com>
Pi left task and direct subagent tool calls in the unknown detail shape, so clients rendered raw request and result payloads. Normalize execution calls at the provider boundary so the existing task card owns their running and terminal states.
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
The badge icon moved from `identityColor` to `identityForeground` when the
status palette was unified, because a stroked glyph on a surface has to clear
contrast against that surface rather than behind white letters. The helper
still expected the fill table, so it read teal as #368080 where the icon now
draws #3e6d6c.
The variant is per-scheme and there is no scheme to read from a Playwright
locator, so the expectation pins "light" and says why.
The sidebar row narrowed to services only and its display-preferences item is
now labelled Services, while the panel and menu that list both types are still
Scripts. That is two words in the UI for things a reader will assume are one,
which is exactly what this file exists to settle.
Checks stopped being a toggle. Showing the icon without its word is a real
third answer rather than half of "off" — the state stays on the row for a
glance and a passing workspace stops paying a word for it on every line — so
it left the boolean row-item record instead of widening it, and a stored
`sidebarRowItems.checks === false` migrates to the new "none".
The services item narrowed to actual services. It used to fall back to any
running command, but a test watcher running says nothing about whether a
workspace is up, and the item now says "Services" so drawing a terminal glyph
under that label was a control lying about itself. It reports the name rather
than the port because the name is what you called it. `health` was already on
the wire and nothing in the sidebar read it, so a failed check now turns the
whole item danger; where several services run, the failing one takes the
single slot, since a healthy one is the unremarkable case and any of them
says "this is up".
That narrowing is why the rename went through the code and the persisted key
rather than stopping at the label: the concept genuinely changed, and a UI
saying "Services" over a `scripts` key is the synonym docs/glossary.md calls a
design error. Both key migrations are tagged COMPAT.
The rest is the sidebar row's geometry. The show-more and new-workspace rows
each carried their own smaller, inset, less-rounded version of a workspace row
— 24pt of left margin, a 14pt icon column, `borderRadius.md`, one fill for
hover and press — which under a column of real rows reads as a mistake rather
than a distinction. They now take the workspace row's geometry, and the
show-more row asks its caller whether it is ending an indented list, because
the two groupings disagree.
Group spacing moved from margin to padding so a collapsed group gives it back
instead of staying spaced for content that is gone. SPACING gained a 0.5 rung
for the 2pt gap between rows.
Three systems were reporting the same states: a full and a muted status
tier in the theme, plus raw palette steps in StatusBadge and
getStatusDotColor. A PR icon was muted in the sidebar and full-strength
in the PR panel, and the Resolved badge used a theme-independent green
so it painted a dark block in light mode. The muted tier is deleted and
every status surface now reads the one token per signal.
Dots keep a band of their own. A dot is a few points of solid colour
with no shape to read and no label attached, and the running one pulses,
so at the status band's lightness it sat below the check icons and host
badge on its own row — backwards, since the dot is the row's state.
Chroma caps at 90% of gamut rather than 100% because at the edge green
reaches C=0.215 while blue manages 0.116 and the set stops reading as
one family; dark lightness caps at 0.72 because red desaturates to pink
above it.
Host badges needed a foreground variant of the identity table. That
table is tuned for a white letter on the colour as a fill; used as text
it cannot clear 4.5:1 on both themes at once, since light needs
luminance <= 0.183 and dark needs >= 0.212. The new set is generated on
the status family's exact lightness and chroma fraction, so a host name
and a CI check in the same meta row carry equal weight instead of the
brighter one winning the row.
Also: PR avatars had their own eight-hex palette and hash duplicating
identity-colors; workspace rows under a project group now indent to the
same rail as status-grouped rows; group headers are muted; and the
pulsing dot is faster and fades further.
Settings lost the workspace host when entering the app-wide route, while desktop bootstrap could restore a removed local connection before the saved registry was ready. Use the remembered workspace host for Settings and only register the managed daemon when its server is unknown after registry hydration.
Beta testers had to find prerelease assets on GitHub by hand. The
download page now carries a Stable/Beta switch, with stable staying the
default and the canonical URL.
The channel lives in the URL (?channel=beta) rather than client state so
maintainers can link someone straight to it. That link outlives the beta
it was shared for, so the switch only renders while the newest
prerelease leads stable on its core version, and a stale ?channel=beta
falls back to stable instead of serving a promoted version twice.
"What's new" deep-links the minor group anchor (/changelog#release-0.3),
not the exact entry: promotion rewrites the -beta.N entry in place, so
that anchor dies while the group survives — and the group is the
stable-to-beta diff a beta downloader came to read. A version missing
from the bundled changelog links plain /changelog rather than a dead
anchor, which covers the window between a tag going public and the site
redeploying.
Homebrew, the app stores, and the web app have no beta, so the Beta view
drops those rows instead of showing inert placeholders.
The mobile terminal stream readiness work made live focus and renderer state recreate the stream controller. A replacement controller could remain untargeted, so input reached the PTY while output stopped reaching the terminal. Keep the controller scoped to the client connection and expose current render state through stable callbacks.
* feat(website): nest docs pages under landable groups
The docs sidebar could already render groups from subdirectories in
public-docs, but nothing used it because every doc was a flat file, and
two things stopped it being usable.
A group had no page of its own, so its label was only a toggle and its
name came from the directory name via formatLabel. A directory's
index.md now supplies the group's label, order, and href, which makes
the group landable and lets the label be authored rather than derived.
Categories and top-level directories named the same thing, so a
categorised doc at hub/index.md produced a group nested inside its own
category. Only directories below the top level become groups now.
Also fixes two behaviours in the sidebar:
- The open/closed effect assigned containsActive on every change, so a
group you expanded to browse collapsed the moment you navigated
anywhere else. Navigating in now opens a group and navigating away
leaves it alone.
- The sidebar scrolls independently of the page and never scrolled the
active entry into view, so loading or refreshing a page far down the
list left the sidebar parked at the top with no visible position.
The group row is a single target: it navigates and expands, and
collapses when clicked again from the page it points at. A separate
chevron button would put two actions a few pixels apart.
* docs(hub): document Hub
Adds a Hub section covering what it is, how it works, daemons,
triggers, configuration, activity, and the two ways to run it.
The section separates using Hub from running it. Overview, quickstart,
daemons, triggers, and configuration hold regardless of who operates
the deployment; the self-hosting branch owns deploying the service and
creating the GitHub, Slack, and Discord apps it talks to. That split
falls on a real seam, because those apps are exactly what a hosted Hub
would own on your behalf. Hosted is a placeholder until it ships.
Each provider gets a trigger page carrying its events, filter
semantics, and the full paseo.event.* reference for the data a prompt
can read. That data is the thing a config author actually needs and
the thing they cannot get from anywhere else.
Every YAML block was parsed against the Hub config schema rather than
eyeballed, which kept several plausible-looking things out of the
docs: worktree prNumber takes a literal number and not a merge
variable, only daemon environments run today, and github.push has no
actor so it can never satisfy the required from_users filter.
Troubleshooting moves to order 90 so it stays last.
* refactor(website): let the active docs entry scroll itself into view
The layout queried the sidebar for the active entry after every
navigation, which coupled it to when that entry mounts. A link inside a
collapsed group only mounts once the group's own effect opens it, so
the query could run against a DOM that does not contain it yet.
Moving the scroll to the entry removes the ordering question rather
than answering it, and drops the ref and the cross-component query.
Reported by Greptile on #2864. The failure it described does not
reproduce: navigating from a page outside a collapsed group into one of
its children still scrolls correctly, verified with the group confirmed
collapsed and the sidebar forced to the top. The coupling was real even
though the bug was not.
* docs(hub): stop listing BETTER_AUTH_URL as required
It falls back to PASEO_CLOUD_PUBLIC_URL, so a deployment that sets the
public origin already has a correct auth base URL. Listing it as
required made the Fly recipe look incomplete when it was not.
Moved to optional with its default, and recorded what actually breaks
without BETTER_AUTH_SECRET: the auth routes close and nobody can sign
in.
Reported by Codex on #2864, which read the same inconsistency the other
way round and proposed adding the variable to the Fly example.
* docs(hub): attach a database in the Fly recipe
The Fly section showed inline config and a secrets command, neither of
which supplied DATABASE_URL. It defaults to localhost:5432, which on
Fly reaches nothing, so the documented deployment could not start.
fly postgres attach sets DATABASE_URL as a secret, so the step is the
attach itself rather than another variable to copy.
Reported by Codex on #2864.
* docs: align Hub self-hosting contract
The archive response already waits for durable archival and owned-worktree cleanup. Assert those final states directly instead of racing a separate filesystem watcher with its own fixed timeout.
Beta tags now reach external testers without per-release dashboard work. Reserve distinct native build slots so successive betas and the promoted stable build can coexist under one app version.
Scale custom glyph paths in measured cell coordinates so box-drawing runs fill their cells on Android. Debounce daemon resize claims at the terminal pane boundary so keyboard layout animation sends only the settled dimensions.
Make provider catalogs compact and content-addressed so repeat launches can reuse the persisted snapshot instead of transferring it again. Remove eager global catalog loading and the stopped-agent defensive tail fetch, while keeping sequence-gap and reconnect recovery as the correctness paths. Add an opt-in production-ID Android profiling build for local startup traces.
Live Markdown block promotion discarded timeline positions, so merging a disjoint history window could sort the final block ahead of earlier blocks. Preserve the source position on every promoted block so timeline merges keep the response intact.
* fix(server): pin workspace file watchers to native backends
Backend auto-detection could select an IPC watcher whose subscription lock deadlocked the Node worker pool during watcher reconfiguration. Select the host-native backend explicitly so filesystem work, logging, and relay DNS cannot be starved by that path.
* test(server): cover native watcher production boundary
Route the existing real filesystem observation test through the same subscription function used by the daemon. This proves native watcher selection and reconfiguration with actual file events instead of only testing the platform selector.
* test(server): synchronize native watcher readiness
Replace fixed startup sleeps with a real tracked-file event barrier. Native backends can resolve their subscription before the initial stream is fully drained, so the integration test now begins assertions only after an event has been delivered and refreshed.
* fix(server): measure native workspace watchers
Route composer attachments through the shared compact menu sheet so headers and rows use the same alignment rails. Base the final-row padding on the device safe area to keep actions clear of system navigation.
The highlight is an overlay inside the pressable rather than a background on
it, because the node Reanimated animates cannot also carry a themed Unistyles
style. An overlay inherits nothing, so a rounded sidebar row flashed square.
Take the corners from the pressable's own style. The row already declares its
shape once, and a highlight that restated it would be a second copy free to
drift; highlightStyle is left meaning only what the highlight is painted with.
Corners apply before it, so a caller can still depart from the row's shape.
The trigger was a vertical ellipsis on compact and a horizontal one elsewhere.
Use the horizontal glyph everywhere: it is the wider shape, and it is the one
sitting above a menu that now presents from the bottom edge.
The button draws at 32pt, under the 44pt touch minimum, and three of them sit
flush beside the title, so the slop can only grow vertically — widening it
would put two buttons' hit areas over the same pixels.
Step the title and project name down one rung of the scale on compact, and put
the host on the project line. On a phone this header is the only thing on
screen that names the machine, because the sidebar carrying that badge is
closed. It follows each host's own badge setting, so a local-only setup stays
quiet, and it yields space before the project name does.
A project name that only repeats the workspace name is still dropped where the
two sit side by side, and kept on compact, where the line exists for the host
anyway. That made shouldShowSubtitle a verdict the header source was not in a
position to reach, so it now reports the fact: isSubtitleDistinct.
Drop the 60% cap on the project name. It resolved against a box whose own width
came from that same text, so it clipped while there was still room beside it;
flexShrink already gives up space only when there is none.
The server glyph, its identity-colour table and the muted label lived inside
the sidebar meta row, and the three stores that decide whether a host has a
badge at all were assembled at one call site. Both are about to have a second
caller in the workspace header.
Move the badge to hosts/host-badge and the resolution to useHostBadges. The
badge now owns its glyph size, and the meta row matches it, since it is the one
item on that line that also appears off it.
Selecting an item parked its action in a ref and waited for the Modal's
onDismiss to flush it. MenuOverlay returns null as soon as the menu closes, so
the Modal unmounted and onDismiss never fired: every close-on-select action was
dropped. Creating a terminal from the compact workspace menu did nothing at all.
Wait on a timer instead. The grace period was always about giving UIKit time to
tear the surface down before a native presenter launches, and a callback fired
from inside a subtree being removed is racing its own removal. The sheet had no
flush at all, so it was broken the same way for a different reason.
Rows are 40pt below md and 28pt above it. The split is on breakpoint rather
than presentation because the compact popover, which compactMode defaults to,
is worked with a thumb just as a sheet is.
Move vertical spacing off the row onto a new MenuPage, which also provides a
page's depth so the popover and the sheet cannot disagree about what a page is.
A row carrying vertical margin would serve both the edge inset and the
inter-row gap at once, and margins do not collapse.
Native font glyph bounds do not reliably fill terminal cells, and long box-drawing runs accumulate advance drift. Paint block and box glyphs from cell geometry while preserving the original Unicode cell model for selection and copying.
Sidebar rows previously waited for JavaScript-driven selection or navigation before showing feedback. Keep the transient highlight on the UI worklet while leaving activation, cancellation, and drag ownership with the existing pressable.
Clearing the hidden terminal input after multi-character commits can leave the keyboard's composing buffer intact, causing later changes to replay committed text. Keep the accumulated native value so subsequent changes emit only their appended suffix.
* fix(app): keep an open workspace menu from unmounting its own trigger
The sidebar kebab is revealed by hover, and on a compact layout the menu it
opens is a sheet that slides up over the row it came from. The row un-hovers,
the trailing overlay unmounts the trigger, and the menu's open state goes with
it — nothing is left that can close the surface already presented in the
portal, so a full-screen backdrop swallows every click until reload.
Lift the menu's open state to the component that decides whether to render the
kebab, and keep the kebab rendered while the menu is up.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(app): follow the sidebar and host badge redesigns in the e2e suite
Three specs were still driving surfaces that moved out from under them, so CI
has been red on main since the sidebar polish landed.
Display preferences became one row per decision with the options a page down,
so grouping and the host filter need that page walked first. Five specs had
their own copy of the two-line dance; it lives in the sidebar helper now, and
the host filter has its own opener next to the menu it belongs to.
The host badge is plain secondary text on the meta line and its identity colour
lives on the server icon alone, so the helpers stop asserting a tinted label
and a pill fill that the redesign removed.
Alignment rails are compared across canvas glyph metrics and SVG bounding
boxes, so the residual depends on the host's font; a 1px and a 0.5px tolerance
both landed on the wrong side of it in CI. Two pixels still catches the whole-
pixel offsets these rails encode. The compact sheet spec also read its rails
while the panel was still sliding in.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* chore(app): let a browser e2e run take a private Metro cache
Metro's on-disk cache is shared by every checkout on the machine. Editing one
app source file rebuilds that module against everyone else's cached output and
the Unistyles styles can come back desynced, which surfaces as alignment specs
failing with real-looking pixel numbers that go away when the edit is reverted.
E2E_METRO_CACHE_VERSION namespaces a run's cache without deleting anyone
else's entries, so the cache can be ruled out before a layout failure is
believed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(app): stop the chat scroll helper from hanging on a stalled sampler
`scrollChatAwayFromBottom` waited on a promise that could only ever resolve
from a `requestAnimationFrame` sampler started by a `wheel` listener. rAF does
not fire while the page is occluded, which is reachable on a CI runner, so the
sampler could stall on its first frame and the wait never returned — the test
died on its own 240s timeout with nothing to read. A missed wheel event did the
same.
Settling the scroll before measuring is an optimization; the assertion after it
is what decides the test. Sample on a timer with a deadline so a stall falls
through to that assertion instead of consuming the test.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(app): remove layout geometry e2e coverage
Rail and pixel-alignment assertions depend on host rendering details and should not define the browser E2E contract. Preserve the surrounding behavior coverage while dropping the Metro cache escape hatch that only supported diagnosing those assertions.
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
Pin the keyboard controller to the release that prevents stale keyboard positioning from leaving the composer underneath a keyboard that is still visible.
* fix(workspaces): default new worktrees to upstream refs
Use the daemon-resolved upstream as the untouched starting ref so unpushed local commits do not leak into new workspaces. Preserve exact base identity for fork remotes while keeping local-first behavior across version drift.
* fix(workspaces): preserve exact base refs in git actions
Resolve accepted display aliases back to the stored ref before comparisons and updates. Fail closed when a non-origin remote base has no explicit mutable merge target.
* fix(workspaces): reject conflicting qualified base refs
Display aliases remain accepted for compatibility, but a qualified ref carries remote identity and must not be canonicalized to a different stored upstream.
* test(server): make upstream fixtures portable
Keep the checkout-status fixture aligned with the optional upstream field, and select main explicitly when cloning a bare test remote whose default HEAD varies by platform.
* fix(workspaces): preserve exact upstream refs
Qualified remote refs now fail closed when pruned instead of switching lineage. Metadata accepts Git-valid upstream names rather than applying a narrower character whitelist.
* fix(workspaces): preserve qualified local base refs
Local tracking branches are exact upstream identities too. Keep heuristics only for legacy unqualified metadata so origin cannot replace a stored refs/heads base.
Replace time-based subscription expiry with a bounded hot set so switching among a small working set and backgrounding the app do not force stale timeline catch-up. Visible chats always retain priority, while disconnect and legacy delivery preserve their existing authoritative boundaries.
* fix(app): preserve chat scroll across workspace switches
Hidden retained panels can emit geometry-driven scroll events while collapsed, which previously looked like user intent and disabled output following. Keep the retained DOM mounted, suspend its inactive scroll work, and only detach from the bottom after explicit input.
* fix(app): classify chat scroll intent at input
A held pointer is not evidence that a geometry-driven scroll was user initiated. Detach output following directly from upward wheel, key, touch, or mouse-drag input, and keep ordinary scroll events responsible only for metrics and bottom reattachment.
* fix(app): keep editing keys out of scroll intent
Question inputs render inside the chat viewport, so their caret-navigation keys bubble through the same keyboard boundary. Treat keys from editable descendants as editing intent while preserving viewport-level keyboard scrolling.
* fix(app): exclude zoom gestures from scroll intent
Ctrl-wheel and trackpad pinch events can carry a negative wheel delta without scrolling chat content. Keep those browser zoom gestures from disabling output following.
* fix(app): require pointer-driven chat movement
Mouse movement alone is not viewport scroll intent. Correlate actual upward scroll with short-lived content-drag evidence or a scrollbar-gutter gesture, while excluding embedded editors and retaining geometry-only following.
* fix(app): recognize overlay chat scrollbars
* fix(app): scope scroll intent to chat viewport
* fix(app): rearm chat history at scroll start
* fix(app): track chat autoscroll intent
* fix(app): separate pointer scroll gestures
Use mobile action sheets and long-press file menus while preserving provider context across sheet portals. Keep sidebar and file-pane glyphs on shared visual rails, avoid scrollbar layout shifts, and hide keyboard affordances where shortcuts are unavailable.
Sidebar polish round.
Menus. `dropdown-menu` and `context-menu` were two copies of one engine that
differed only in how the trigger fires — identical from `computePosition` down —
and neither could nest. Both are now thin wrappers over
`components/ui/menu/`, which owns anchoring, a pure page-stack navigation model,
and one presentation fork: flyout on pointer, pushed pages in a sheet on compact.
All 31 call sites are unchanged. Items got a rounded inset fill, a visible
separator, and `selected` (a check) split from `active` (a fill).
Sidebar display preferences. The flat wall of four sections becomes one row per
decision with its value, and per-feature toggles over what a workspace row shows:
host, pull request, checks, scripts. Each is independent — a ticked toggle that
draws nothing is a lie. `display-preferences/` hides that grouping lives in
zustand while title source and row items are app settings.
Timestamps. "Last activity" now ticks itself. The label rounds to the minute and
never shows seconds; past a week it becomes a date. The timer lives in the leaf
`<Text>`, at most one per resolution tier shared by every row, and a tick only
re-renders when the string actually changed — so a memoized row that legitimately
never re-renders still stays current.
Colour. Full status tier regenerated at 90% of gamut max (was 85), same rule as
the muted tier, lightness and hue untouched. File icons are pulled toward neutral
in OKLab behind `getFileIconSvg` — material-icon-theme picks its palette for a
tree where the icon is the only colour on the row, and ours is not. New/deleted
files use the forge square-plus/square-minus glyphs at muted weight instead of
tinted pills. Project icons round a little more; uploaded icons never do.
The project status badge is a knockout again: it fills with the colour of the row
behind it, so the ring reads as a gap in the icon rather than a white halo.
File rows. The always-visible kebab is gone from the file explorer and diff tree
in favour of right-click, which also reclaims the reserved action slot.
* fix(app): stabilize native terminal keyboard input
Terminal focus requests blurred and remounted the hidden input even when the software keyboard was already visible, causing visible IME cycles and leaving clipboard commits coupled to stale native text. Keep focus requests idempotent while resetting only the native input buffer.
* test(app): remove misleading keyboard device scenario
Agent Device cannot durably assert software-keyboard continuity or commit a keyboard clipboard suggestion, so the scenario overstated coverage already owned by focused input-policy tests.
* fix(app): derive terminal keyboard focus from visibility
Use the keyboard-controller inset as the source of truth so a system-dismissed keyboard cannot leave focus activation suppressed. Avoid a duplicate focus request while explicitly showing the keyboard.
* fix(app): track terminal keyboard visibility across layouts
Keep keyboard visibility independent from compact-only terminal padding so tablet taps remain idempotent while the software keyboard is open.
The Cloud page pitched a hosted multi-user product and collected emails
through a signup form. The Hub is a self-hosted service in private beta
and the only way in is a Discord DM, so the signup server function and
its webhook are deleted rather than repointed. /cloud now 301s to /hub
from the worker entry, which already owns the site's permanent path
redirects, so old links and search results keep working.
The homepage also drops split panels, service proxy, voice, and keyboard
shortcuts. They had turned the page into a feature list; it should show
what Paseo enables at a high level.
Brand and provider icons move into shared modules so the Hub page, the
site header, and the landing page each use one copy instead of three.
* feat(hub): pass execution MCP servers to agents
Hub-owned execution capabilities need to reach provider sessions without becoming part of outward agent snapshots. Keep the configuration daemon-private so provider recovery continues to work.
* fix(hub): protect daemon MCP namespace
Hub execution MCP configuration must not replace the daemon-owned Paseo server. Validate only after owned-record lookup so idempotent replay remains stable while new launches fail before provider or durable state creation.
* fix(agent): reject unsupported MCP launches
* fix(agent): scope MCP support guard
* fix(agent): redact MCP metadata from wire payloads
* fix(agent): validate MCP support on restored sessions
* fix(session): rehydrate MCP config on resume
* fix(session): resume newest matching agent record
* fix(session): restore archive state after failed resume
* fix(app): preserve formatting when copying assistant selections
Browser selection serialized React Native Web layout nodes instead of the assistant message semantics. Recover semantic markup from renderer-owned metadata and reuse the rich clipboard generator used by Copy turn.
* fix(app): preserve assistant copy semantics
Carry source-only Markdown metadata through the selection copy boundary, unwrap generated code links, and cover ordered starts, hard breaks, and fence languages in Playwright. Refresh the Nix dependency hash for the added clipboard dependency.
* fix(app): preserve partial selection context
Wrap selected fragments with their shared semantic ancestor chain and enable GFM table and strikethrough conversion. Cover partial nested selections and GFM output through the browser clipboard.
* fix(app): preserve copied list and autolink text
Adjust partial ordered-list starts for omitted siblings, strip parser-generated anchors before Markdown reconstruction, and refresh the Nix dependency hash computed by CI.
* fix(app): normalize partial selection context
Normalize cloned ordered lists across block boundaries, degrade incomplete table fragments to selected text, and preserve table alignment provenance through Markdown reconstruction.
* fix(app): escape copied table delimiters
Rendered table cells no longer retain source escape characters. Escape literal pipes during DOM-to-Markdown conversion so copied tables preserve their column structure.
* fix(app): preserve authored link semantics
The rendered link boundary conflated parser-generated links with authored autolinks and discarded authored titles. Carry title semantics into the anchor and unwrap only links with explicit generated provenance.
* fix(app): preserve nested copy structure
Blank-line splitting separated loose nested lists before Markdown rendering, leaving no nesting for clipboard serialization to preserve. Derive protected separators from the Markdown block structure and keep copy metadata independent from monospace surface styling.
* fix(app): preserve selected file links
Assistant messages and rich clipboard HTML must apply the same link validation policy. A shared parser keeps file links semantic in both MIME representations while retaining markdown-it's unsafe-scheme rejection.
* fix(app): preserve partial table content
A selected body row wider than its retained header cannot be represented as GFM and is truncated when rich HTML is regenerated. Treat only those over-wide fragments, plus headerless fragments, as incomplete while preserving valid short rows.
* Improve mobile terminal typing and selection
* Address mobile terminal review feedback
* Remove dead native terminal input branch
* Replay bracketed paste in terminal preamble
* Stop per-key terminal resize claims
* Stabilize terminal resize browser test
* Use tracked terminal mode for native paste
* Restore terminal toolbar focus handling
* Adapt native terminal to active visibility state
* Stabilize native mobile terminal interactions
* Add native terminal fallback boundary
* test(app): expect explicit terminal resize reclaim
* fix(app): stabilize terminal buffer coordinates
* fix(app): restrict terminal resize ownership
* fix(terminal): gate restored input modes
* fix(app): recover stalled terminal WebViews
* chore(app): remove obsolete terminal harnesses
* fix(app): make terminal resize claims explicit
* test(app): stabilize native terminal device flows
* test(app): track absolute scrollback coordinates
* fix(app): isolate terminal WebView streams
* fix(terminal): arbitrate size ownership across clients
The daemon now distinguishes explicit ownership claims from passive geometry updates. This keeps the active client authoritative while allowing its split, viewport, and keyboard changes to resize the PTY without idle clients stealing it.
* test(server): make worker resize assertion portable
Query the worker's authoritative terminal snapshot instead of invoking a Unix-only shell command, so the resize regression exercises the same boundary on Windows and Unix.
* feat: add DeepWiki MCP server at project level
* feat(omp): poll context window usage during active turns
OMP agents previously reported context window usage only at turn
completion, unlike Claude and Codex which stream live updates. Add a
periodic poll (3s interval) of getSessionStats() that emits usage_updated
events while a turn is active, so the client context window meter
updates in real-time.
* clear context usage poll timer before scheduling next interval
* chore: remove .omp/ directory
* fix(omp): make live usage polling lifecycle-safe
OMP exposes live usage through a stats request rather than stream events. Keep polling scoped to active turns, suppress unchanged snapshots, and invalidate stale completion reads across interrupts, new turns, and session shutdown.
---------
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
* fix(nix): identify desktop app for icon matching
* fix(desktop): ignore wrapper class in CLI passthrough
* fix(nix): give the Electron launcher a real app root
The Wayland app_id — what a bar or taskbar uses to find the .desktop
entry, and therefore the icon — is fixed during Electron startup, so
neither `--class=` nor main.ts's `app.setName()` reaches it on current
Electron. Launching `electron path/to/main.js` gives Electron no
package.json to read a name from, so the window published the default
"electron" app_id and shells drew the generic Electron icon.
Launch a one-file app root named "paseo-desktop" instead, matching the
installed desktop entry and hicolor icon. Older Electron (38) derives
the app_id from the runtime app name "Paseo", so also ship a NoDisplay
alias entry under that name; the icon resolves either way and the
launcher list still shows a single Paseo.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* perf(server): bound Git process starts
Git refresh bursts could keep creating short-lived processes at the concurrency ceiling, causing sustained process churn on constrained hosts. Apply one daemon-global start-rate and concurrency policy to every Git command while preserving the legacy concurrency environment variable.
* build(nix): update npm dependency hash
* fix(server): hold Git slots until process exit
* fix(server): enforce Git limits across process lifetime
* fix(server): align Git metrics with process exit
* Fix Git limiter test deadlocks
* Tune default Git process rate
* fix(composer): keep background context out of loading state
Pull request link resolution can block a new workspace until its checkout target is known, but it is not submission work. Keep that safety gate while reserving the send-button spinner and drop lockout for actual submission or upload progress.
* test(composer): cover link resolution at browser boundary
Exercise the real new-workspace composer while pull request context resolves, including the submit presentation and file-drop path. Keep the two presentation booleans local to their only caller instead of exporting policy solely for an internal-state unit test.
* fix(composer): preserve background lookup ownership
Composer edits could release the resolving gate while a pull request lookup still owned the request, briefly allowing workspace creation from the wrong branch. Track logical candidates independently of unrelated draft changes and settle each reference independently so only invalid work releases early.
* fix(composer): preserve pasted PR selection order
* fix(composer): renew re-added PR lookups
* fix(composer): track pasted PR source order
* fix(composer): preserve resolved attachment order
* fix(composer): coordinate active lookup order
* fix(app): align turn footer actions
Keep running-turn actions in the same position as completed-turn actions and use the shared small icon size for both copy and fork controls.
* feat(app): improve sidebar workspace identity
Make dense sidebar workspace rows easier to scan and let each device control how connected hosts are identified.
* fix(app): keep host badges readable
Use a compact display menu and keep identity color on the host icon so translated controls fit and badge text retains standard contrast.
* fix(app): announce selected sidebar badge mode
* fix(app): retain icon-only host names for screen readers
* fix(app): name hoisted workspace rows
* fix(app): retain host identity in workspace labels
* fix(app): retain workspace status in row labels
* fix(app): defer host appearance until identity resolves
* fix(app): serialize host appearance writes
* fix(app): serialize host registry writes
* fix(app): bound sidebar host badge labels
* test(app): match workspace status labels
* fix(app): consolidate sidebar identity behavior
* test(app): assert status grouping behavior
* fix(app): keep sidebar row actions accessible
* fix(app): stabilize optimistic message layout
Native composers had no path from the device clipboard into image attachments. Route clipboard data URLs through the existing attachment persistence flow and expose the action only in the native attachment sheet.
* perf(app): retain inactive workspace screens
App-wide routes cleared the active workspace selection, which removed the retained workspace deck from the React tree and made returning rebuild every panel. Keep mounted deck entries inactive across those routes and suspend inactive panel subscriptions so state is preserved without background work.
* perf(app): suspend remaining inactive queries
Retained composers and commit diff panels still subscribed through query hooks that did not receive panel activity. Propagate the existing activity gate so app-wide routes leave those hidden surfaces dormant.
* refactor(app): separate panel activity from connectivity
A hidden retained panel is still connected to its host. Keep connectivity truthful, pause work inside query and stream owners, and preserve terminal emulator identity while its stream is detached.
* test(app): target active retained controls
* fix(app): retain compact and terminal route content
* fix(app): hide inactive workspace overlays
* fix(app): keep timeline pagination duplicate-free
Older daemons page projected history by canonical sequence ranges, so one projected block can appear in adjacent responses. Accept each block on the page containing its projection anchor while still advancing across empty projected pages.\n\nProjected siblings can share a provider message ID, so render identity also includes the timeline cursor instead of collapsing distinct blocks onto one layout row.
* fix(e2e): preserve isolated daemon launch contract
Keep the source-daemon spawn path recognizable to the repository launch-contract check while selecting the built supervisor only for published package fixtures.
* fix(app): preserve stable timeline item identity
* fix(server): include native subagents in workspace activity
Provider-native children live outside managed agent snapshots, so background work could outlive an idle parent without keeping the workspace running. Aggregate running child descriptors into the owning workspace and publish workspace updates on child lifecycle changes.
* fix(server): serialize workspace status updates
Queue overlapping descriptor rebuilds per workspace so older activity snapshots cannot overwrite newer lifecycle state. Preserve batched registry reads for multi-workspace updates.
* test(server): update workspace status fixtures
Provide the native subagent activity dependency in the shared session fixture and wait on observable git-watch output across the serialized update boundary.
* fix(server): drop queued workspace updates on subscription swap
---------
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
* fix(acp): preserve permission choices
ACP question bridges encode answers as repeated permission options. Preserve each option through the UI and return the exact selected ID, while keeping auto-accept from choosing on the user's behalf.
* fix(acp): validate selected permission behavior
Reject inconsistent action IDs instead of allowing the action ID to override the response behavior. Move the misplaced browser spec so the current base CI contract can run.
* fix(acp): preserve invalid permission requests
Validate selected ACP actions before consuming the pending request so inconsistent client responses can be corrected and retried.
* test(acp): verify real permission questions
* fix(acp): reject empty permission action ids
* test(e2e): scope Kimi config to ACP spec
* fix(server): fail agents when provider process exits
The transport previously closed pending RPCs without terminating the active manager run, leaving the agent stuck in running state. Propagate unexpected process termination as a one-shot turn failure while suppressing it during intentional session close.
* fix(server): keep idle provider exits recoverable
A process death with no active foreground turn is not a failed turn and must not trigger turn-based lifecycle actions. Leave the session disconnected so the next prompt reconnects without publishing a false terminal event.
* fix(server): preserve provider exit recovery
Connection cleanup after a failed reconnect must not close the durable session boundary or discard AgentManager subscribers. Treat both foreground and autonomous root turns as active when propagating process death.
* test(server): make process exit harness portable
* fix(server): clear permissions after provider exit
* fix(server): preserve plan approval after provider exit
* fix(server): serialize provider reconnects
* fix(server): stop reconnects after session close
* fix(server): preserve provider subagent messages
The provider SDK defaults to forwarding only child tool activity, which leaves the nested timeline without the child response. Request the full nested transcript so provider subagent panes retain both sides of the conversation.
* fix(app): correct project status badge test imports
* fix(ci): skip unrelated required checks cleanly
Matrix jobs had to start no-op runners to preserve interpolated required-check names. Static named jobs let GitHub report genuine skipped conclusions while keeping those names stable.
* fix(ci): gate changes to install patching script
* fix(ci): route cross-package test contracts narrowly
* fix(ci): keep routed contracts merge-blocking
* fix(server): exclude nested test utilities from builds
* fix(ci): cover app-owned native and browser domains
* refactor(ci): align tests with runtime domains
Own browser specs, desktop specs, and Electron-only app code by stable directories so PR routing does not depend on filename conventions. Keep desktop integration coverage inside the required desktop check.
* fix(ci): keep routing and desktop suites isolated
The routing regression must run before dependency installation, and desktop Vitest must not collect Playwright-owned specs.
* fix(ci): isolate desktop e2e runtime
* perf(test): isolate Playwright workers
* perf(test): parallelize Playwright within specs
* fix(test): keep Playwright file scheduling
* fix(test): share dynamic Metro fixture
* fix(test): preserve own-host restart fixtures
* fix(ci): preserve routed contracts after rebase
* Stabilize Playwright wheel-scroll assertions
* Add collapsed-project status badge to the sidebar
Collapsed project rows now surface the most urgent hidden workspace's
status as a corner badge on the project icon, instead of giving no
signal at all: a loader for running, an alert for needs_input, a dot
for attention/failed, nothing for done. Priority order (needs_input >
failed > running > attention > done) means a working project keeps
its loader even if another workspace is also awaiting review.
Every surfaced status shares one badge shell (size, ring, offset) so
the loader, alert, and dot never draw a differently-sized circle -
previously the loader/alert used a 12pt ring while the dot used a
7-9pt one with a different corner offset. The running loader is also
nudged half a point left so its visual center lands on the circle's
center rather than reading as shifted right.
Adds an e2e capture spec (run on demand, not part of CI) that seeds
every state and edge case through the mock provider and dumps DOM
geometry, for re-verifying the badge shell stays uniform.
* Add PR review gallery images for the status badge PR
Temporary evidence assets for pull request review, embedded via blob
URLs in the PR description. Safe to delete after merge.
* Swap misleading before/after PR images for clean per-state stills
This feature never shipped on main, so there is no real "before" to
compare against for a reviewer — the earlier before/after images were
an artifact of local dev iteration, not a regression fix. Replaced
with one clean still per status.
* Fatten needs-input alert and dot to fill the shared badge shell
Lucide's circle-alert only paints ~83% of its nominal size, so the
prior 10pt glyph read as smaller than the loader inside the same
14pt shell. Sized both up so all three glyphs draw the same visual
footprint against the shared ring.
* Address review: extract pure badge-content selection, drop JSDOM tests
Greptile flagged getStatusDotColorStyle's needs_input/running cases as
dead (unreachable — shouldRenderSyncedStatusLoader and an explicit
check already intercept those buckets earlier). Extracting the
selection logic into a plain-TS descriptor function
(getProjectStatusBadgeContent) makes that structural: the "dot"
variant is typed to failed|attention only, so there's no switch left
with unreachable branches.
That extraction also resolves the JSDOM component test Greptile
flagged (project-leading-visual.test.tsx used vi.hoisted/vi.mock/
createRoot mounting, all banned by docs/testing.md's two test
categories) — the pure selector is now tested directly with a plain
vitest file, no mounting needed. Dropped that test file and
use-sidebar-project-status-bucket.test.tsx (same banned pattern); the
real coverage was already redundant with sidebar-workspaces-view-model
.test.ts's existing priority-order assertions and the e2e capture
spec's real-app gating checks.
Also split a conditional branch in the e2e capture spec's assertion
loop into two deterministic loops (badge-expected vs no-badge cases)
per Greptile's third note.
* Size status-badge glyphs even so they center in the shell
The badge shell's interior is 12pt (14pt minus the 1pt ring on each
side). A centered glyph of size N sits at a (12 - N) / 2 offset, which
is fractional for odd N and gets snapped to a device-pixel boundary,
rendering visibly off-center (~1.5 device px at 3x). Drop the dot to 8pt
and the alert to 10pt so both divide the interior into whole pixels and
land dead center; Lucide's circle-alert paints ~83% of nominal, so 10pt
draws a ~8.3pt disc that matches the 8pt dot.
Also extends the on-demand capture spec to cover the failed bucket, the
priority aggregation cases, dark mode, hover, a compact viewport, and a
recorded running-to-expand transition, dumping DOM geometry that proves
every shell shares one size/offset and every even glyph centers at 0px.
* Refresh status-badge PR assets for the even-glyph sizing
Recaptures every state at the approved 8pt-dot / 10pt-alert sizing,
adds the failed bucket to the per-status set, a crosshair sizing/
centering proof, dark-mode and compact-form-factor shots, and replaces
the soft upscaled spinner GIF with a crisp one assembled from native
3x-scale frames. Drops the stale pre-sizing images.
* Add capture spec proving workspace statuses roll up to the project badge
Seeds three collapsed projects, each with several same-directory
workspaces in different buckets, so each resolves to a different
highest-priority winner (needs_input / failed / running). Films the
collapse and expand so the aggregation is provable as motion, not a
single frame.
* Add propagation recording showing statuses resolving to the project badge
* Add higher-quality mp4 of the status propagation recording
* Refactor project status badge coverage
---------
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
* Add automatic plus branch maintenance workflow
* Build plus artifacts for desktop and CLI/server
* Document and summarize plus artifact workflow
* Document Apple signing and pin plus workflow actions
* Disable Linux plus desktop artifact
* Publish plus CLI packages to GitHub Packages
* Fix GitHub Packages publish paths
* Fix plus mise install summary package name
* Disable macOS x64 plus desktop artifact
* Tag plus npm packages as latest for mise
* fix(worktrees): respect selected local base ref
* chore(ci): remove fork-only plus workflow
* fix(worktrees): preserve exact branch picker refs
---------
Co-authored-by: Matt Cowger <1929548+mcowger@users.noreply.github.com>
* feat(acp): auto-accept shared permission requests
Some ACP agents expose permission requests without a native mode that can bypass them. Keep the opt-in at the shared adapter boundary so every ACP implementation gets the same per-session behavior.
* test(acp): include the shared feature in provider expectations
* fix(acp): honor unattended auto-accept intent
* fix(agent): list client features without a model
* feat(app): fork an agent session mid-run from the tab menu
Forking already existed, but only on the footer of a *completed*
assistant turn. While an agent is running, the in-flight turn renders
RunningTurnFooter with no fork menu, so exploring another path meant
scrolling back to hunt for an older turn's footer — or waiting for the
run to finish.
Add two entries to the agent tab context menu ("Fork chat in a new tab",
"Fork chat in a new workspace"), reachable regardless of run state. They
omit the fork boundary, and selectForkContextRows projects the whole
timeline when no boundary is given, so a mid-run fork carries everything
up to now — including the partially streamed in-flight turn.
Fork logic moves out of agent-stream/view.tsx into a shared useForkAgent
hook so the turn footer and the tab menu share one code path; the
per-turn behavior is unchanged (it still pins a boundary to its turn).
No server, protocol, or capability changes — this reuses the existing
agent.fork_context RPC and agentForkContext host feature. The daemon
handler is already a pure in-memory timeline read, so it was safe
mid-turn before this change.
* fix(app): resolve missing agent records before failing a fork
handleForkAgent read the agent record from the session store and gave up
if it was absent from both agents and agentDetails. A persisted agent tab
can outlive its record — restored on startup before hydration — so both
fork entries failed with a generic toast even though the agent exists on
the daemon and is perfectly forkable.
Fall back to client.fetchAgent and store the result before failing,
mirroring the resolution path agent-panel.tsx already uses. Only report
forkFailed when the agent genuinely cannot be resolved; a disconnected
host now says so instead.
The resolution logic lands in resolveForkAgentSource with injected
dependencies, since workspace-screen.tsx has no handler-level test
harness and a sibling testable module is the pattern in this directory.
storeFetchedAgentDetail moves out of panels/agent-panel.tsx into
utils/agent-detail-store.ts so the workspace screen doesn't import a
panel module — and into its own file rather than agent-directory-sync.ts,
which would close an import cycle through legacy-daemon-workspaces.
* feat(app): fork from the in-flight turn instead of the tab menu
Move the mid-run fork affordance to where the fork button already lives:
the assistant turn footer, beside the progress loader.
RunningTurnFooter previously showed only a spinner and elapsed timer, so
the fork button vanished for the whole duration of a run. It now renders
AssistantForkMenu alongside them, reachable the moment a run starts.
The in-flight fork passes no boundary, so selectForkContextRows projects
the whole timeline and the fork captures the response being streamed. A
fork button sitting next to a live response that silently dropped that
response would be worse than no button. Completed turns still pin their
own boundary, enforced by a separate handler type that keeps boundary
required.
layout.ts is untouched — the affordance is added to the running footer
component rather than by lifting the footer suppression, so no copy or
duration affordance leaks onto an incomplete turn.
Reverts the workspace tab context menu approach, along with the agent
resolution helper and store-writer extraction that existed only to serve
it. The footer path already holds the agent record.
* test(app): cover the in-flight turn fork with a Playwright spec
Extends the existing assistant fork menu spec rather than adding a
sibling file, so the completed-turn and in-flight cases sit side by side.
The new test pins the behavior that defines the feature: the in-flight
fork omits the boundary fields entirely, while the completed-turn fork
sends one. Both assertions read the actual agent.fork_context frames off
the WebSocket, using the read-only page.on("websocket") idiom from
helpers/timeline-delivery.ts so the observer cannot perturb the flow it
measures. Note the request omits the fields rather than nulling them —
daemon-client spreads them conditionally — so absence is what's asserted
there, and null only on the response.
Also asserts the fork trigger is scoped inside turn-working-indicator,
that no copy or duration affordance leaks onto the incomplete turn, that
the attachment carries the streamed text, and that the source agent keeps
streaming afterwards.
* test(app): cover the in-flight fork call site, gate, and teardown
Three behaviors this PR introduced had no coverage below the Playwright
spec. A copy-paste of resolveAssistantTurnForkBoundary into
handleForkInFlightTurn would have left every unit test green.
view-fork-in-flight.test.tsx mounts the real AgentStreamView with the
render strategy stubbed to the live-auxiliary slot, so handleForkInFlightTurn,
useStableEvent and the readOnly expression all stay real. It asserts the
fork request has no boundary KEY — matching the wire contract, where the
field is absent rather than null — for both targets, and that the read-only
gate withholds the trigger, with positive controls either side so an empty
tree cannot pass for a working gate.
turn-footer.test.tsx covers the race this PR created: the fork menu now
mounts only while a run is in flight, so a fork can be pending when the
subtree is torn down. The behavior is benign — React 18 makes the orphaned
setState a no-op — so the test asserts the observable outcome and leaves a
tripwire for unhandled rejections, since AssistantForkMenu's finally has no
catch.
Each test was verified by mutation: adding boundary: undefined, dropping the
readOnly gate, and keeping the running subtree alive each fail the matching
test.
Scoped to what this PR changed. The relocated hook's error paths, draft
setup construction, and attachment metadata predate it and stay untested
here.
* test(app): replace mocked fork coverage with Playwright
---------
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
* feat(app): preview HTML files in the file pane
Renders .html/.htm files as a page in the file pane, with the same
Preview/Source toggle Markdown already has. The main use is reading
richer visual plans an agent wrote as a self-contained HTML page.
A preview is a viewer, not a browser. Each document is prefixed with a
doctype and a strict policy, so the policy is always the first element
and always lands in the parser-created head: inline script and style
run, and remote subresources, fetch, XHR, WebSocket, beacon, and form
posts are refused. The frame has an opaque origin, so it cannot reach
the app's DOM and storage and cookie APIs throw inside it.
One gap is left on web and cannot be closed there: a sandboxed
document may navigate itself, and no CSP directive in current browsers
prevents it -- navigate-to was dropped from CSP3 and is unenforced, and
meta refresh needs no script (both verified against the Chromium the
app ships against). The opaque origin bounds it to the page's own
contents, and SECURITY.md documents it. Native narrows it further by
refusing navigation after the initial document, with the caveat that
the decision runs in app JS.
Self-contained pages only -- a page that pulls a CDN script renders
unstyled, and Source still shows everything.
* fix(app): harden HTML preview behavior and tests
---------
Co-authored-by: Nicholas Salgueiro <nicholas.salgueiro@britecore.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
Generated titles optimized for topical brevity and could omit the requested operation or the identifier that distinguished the task. Preserve the operation, target, and strongest anchor so sidebar titles remain actionable.
* feat(app): expand command center actions
Keep command center navigation stable by revealing only clipped rows without smooth centering. Prioritize a concise default list while making workspace, Git, pane, and creation actions searchable through the existing contribution and action-policy paths.
* test(app): search for query-only command action
Add project is intentionally absent from the unfiltered command center. Exercise the overlay flow through search before selecting it.
The outer test deadline could expire before the archive observer's own timeout after slow Windows setup. Leave enough room for daemon and Git setup so the operation-specific failure remains authoritative.
* Reapply: Stop completed turns from appearing stuck (#2484)
* Reapply: Keep older chat history and image previews stable (#2490)
* Lock timeline layout stability with user-driven specs
Two reds reproduce the reported jank:
- new agent, first prompt: the row paints at top=100 then jumps to 144 through
authoritative hydration, because the draft tab and the agent panel each render
their own AgentStreamView for the same logical row.
- long loaded timeline, submit: scrollHeight collapses 10793 -> 9111 mid-turn,
because splitOrderedTail reclassifies measured rows into the virtualized segment
where the virtualizer falls back to estimateStreamItemHeight.
Also restores centered timeline rows: the pagination anchor wrapper introduced in
#2490 was a plain block div, which broke alignSelf: center.
* Measure the turn footer blink from when the footer appears
Bounding the window at the prompt row conflated two things: the footer arriving a
frame after the row, and the footer leaving the layout and coming back. Bounded from
the footer's first frame, the blink still reproduces, so the footer-ownership race is
real rather than a sampling artifact.
* Rename the shadowed prompt row binding in the frame recorder
* fix(desktop): allow concurrent worktree dev instances
Desktop terminals can inherit another instance's profile path and debugging flags. Scope the profile to the current dev root and select an available debugging port so instances do not share global runtime state.
* fix(desktop): expose development providers in dev builds
Desktop-managed daemons inherited no Paseo runtime mode, so dev builds constructed the production provider registry. Mark unpackaged internal launches as development while continuing to force packaged launches to production.
* fix(timeline): stabilize optimistic turn layout
Keep initial history loading dormant until user intent and preserve active-turn layout across delayed transport updates. Add painted-frame coverage for optimistic timeline transitions.
* fix(timeline): preserve optimistic prompts through hydration
Canonical replacement relied on a transport submission record that can settle before a provider echoes the user row. Keep presentation continuity owned by the unreconciled row while destructive history replacement remains governed by active transactions.
* fix(timeline): make submitted prompts canonical
Submission settlement previously depended on status updates that could arrive after the optimistic row. Persist every accepted prompt in the canonical timeline and keep the footer active until RPC and canonical acknowledgement converge.
* fix(timeline): stop older history pages repeating entries
Backward pagination counted source sequence rows, so page boundaries inside a merged projected item could return that item repeatedly. Count projected entries and advance from their display anchors instead, with server and browser regressions covering the contract.
* fix(timeline): resume agents from the latest history
Use a bounded tail probe on resume so long background runs appear atomically instead of replaying every missed page. Preserve unchanged views, reconcile local/live rows, and keep skipped history reachable through backward pagination.
* fix(timeline): reconcile turn state after missed updates
Turn completion events can be missed while an agent timeline is unsubscribed, leaving footer activity stale even after the directory reports idle. Reconcile the single-owned turn lifecycle from authoritative resume snapshots while preserving newer live evidence.
* test(timeline): cover running state across refresh
Protect the hydration boundary where directory state must restore consistent tab, composer, and footer activity. Expose the running indicator semantically so the regression checks user-visible state instead of spinner implementation details.
* fix(timeline): keep history loading geometry stable
Reserve a fixed history-start gutter regardless of pagination state so showing or hiding the loader cannot shift timeline content.
* fix(timeline): keep turn activity coherent
Derive footer, tab, and composer activity from one lifecycle and submission projection. Idle directory transitions now request an authoritative resume so missed terminal events converge without treating directory delivery order as turn authority.
* fix(tests): align checks with runtime boundaries
* fix(desktop): stop dev app surviving its terminal
Electron was detached from the desktop runner, allowing it to outlive a stopped workspace terminal with broken output pipes and retain the single-instance lock. Keep Electron owned by the runner and preserve explicit process-group cleanup for detached child trees.
* fix(timeline): preserve activity through turn handoff
Correlate lifecycle events and snapshots with daemon-owned turn identity, and keep optimistic submissions active until their canonical row arrives. This prevents stale updates and RPC timing from exposing a false idle state.
* refactor(timeline): remove submission timing state
* fix(timeline): restore prompt identity on resume
* fix(timeline): exclude attachments from canonical prompt text
Structured provider prompts can include hidden history attachments before the user's text. Persist only plain prompt blocks so authoritative user messages match what the composer displayed while provider input remains unchanged.
* refactor(agent): remove unused pending-run message identity
* fix(desktop): let Chromium allocate the debugging port
* refactor(agent): remove impossible finalized-turn tracking
* fix(timeline): load history after keyboard scrolling
Treat observed upward displacement as user intent so keyboard and scrollbar navigation can wake dormant pagination.
* test(server): preserve accepted turn stream order
The manager-owned accepted turn boundary intentionally precedes coalesced output. Assert that lifecycle event explicitly while retaining the timeline-before-terminal ordering contract.
* test(app): scope sidebar fixtures by identity
Shared E2E daemon state may contain unrelated projects. Keep stale-route coverage tied to its named missing project and drag only the two rows seeded by the reorder spec.
* fix(timeline): gate prompt revisions by client capability
* fix(timeline): finalize canonical prompt delivery
Treat provider identity as a single stored-row transition so legacy clients receive one deliverable canonical prompt. Release identity-pending rows before later foreground output or the current terminal while preserving stale-turn isolation.
* fix(server): preserve legacy prompt pagination
* fix(timeline): keep assistant block identity stable
Assign canonical block IDs during live promotion so terminal finalization cannot remount image-bearing rows.
* fix(timeline): apply covered revisions during resume append
Run the exact canonical revision pass before overlapping resume pages append newer rows, while leaving cursor advancement to the incremental path.
* fix(server): preserve queued agent transitions
Project each queued live snapshot before awaiting stored metadata so later lifecycle state cannot overwrite an earlier transition.
* fix(server): snapshot queued agent updates
Capture each live agent payload before it enters the per-agent queue so shared nested state cannot change while an earlier storage read is pending.
* docs(app): date agent turn compatibility cleanup
* refactor(timeline): mint submitted prompt identity at acceptance
Canonical prompt rows now keep one immutable wire identity while provider identity remains internal for rewind translation. This removes the unreleased same-sequence revision and legacy projection contract.
* refactor(app): remove prompt revision reconciliation
Submitted prompt rows are immutable on the wire, so resume and live reducers no longer need to apply already-covered same-sequence updates.
* test(app): cover submitted prompt rewind roundtrip
* docs(timeline): describe immutable submitted prompt identity
Provider echoes now enrich daemon-internal identity only; wire rows keep their acceptance-time identity and rewind performs the provider translation.
* test(server): validate mock rewind targets
Reject unknown provider message IDs so app rewind coverage exercises wire-to-provider identity translation.
* fix(server): validate rewind before interrupting
* fix(server): omit rewind identity for daemon prompts
* test(app): guard rewind eligibility and rejection
* fix(server): preserve prompt identity across hydration
* refactor(timeline): remove stale revision remnants
* Revert "fix(server): preserve prompt identity across hydration"
This reverts commit 6adcf7e5b0.
* fix(timeline): settle missed submission acknowledgements
* fix(workspaces): keep restored merged workspaces active
Auto-archive previously reacted to every merged pull request snapshot, so restoring a workspace immediately archived it again. Persist the consumed change request with the archive transition so explicit recovery remains authoritative.
* test(e2e): clean partial archive scenarios
Track workspace resources as soon as creation succeeds so later setup failures archive the worktree and remove the registered project.
* test(workspaces): pin archive latch restoration
Cover the persisted archive marker across restore in CI and make the real browser assertion observe active workspace stability after refresh.
* fix(workspaces): latch restored legacy archives
Resolve the current merged change request while restoring records created before the archive marker existed, then persist it with the unarchive mutation.
* fix(workspaces): refresh latch on restore
When an archived workspace now points at a different merged change request, record that URL during restore so the restored workspace remains active.
* test(e2e): make archive latch setup fail loudly
Remove authentication-gated skipping and register each external fixture cleanup immediately after allocation so setup failures cannot leak resources.
* test(workspaces): include archive latch in reconciliation
Dictation auto-send bypassed the controlled composer update, so preserved draft surfaces could lock while showing an empty prompt. Publish the transcript before invoking the existing submit lifecycle so pending and failed submissions retain the user's spoken input.
* fix(app): exit focus mode for sidebar shortcuts
Focus mode kept sidebar actions hidden after their handlers ran. Exit only after a sidebar action is handled so the requested action still takes effect.
* fix(app): order focus exit before sidebar action
Leave focus mode before dispatching the sidebar command so its handler runs against the visible panel state.
* fix(app): preserve focus mode outside workspaces
Only exit active workspace focus mode after a sidebar shortcut is handled. Keep persisted focus state unchanged when the shortcut has no foreground workspace target.
Package only the traced desktop and daemon runtime graph, preserve required runtime entrypoints, and exclude the npm Electron distribution supplied by nixpkgs.
The Windows runner could observe the marker after creation but before its contents were written. Wait for the expected value so the test synchronizes on the behavior it asserts.
* fix(app): show worktree names in workspace hover cards
Managed worktree paths exposed Paseo's internal directory layout instead of the useful worktree name. Carry the owned worktree root through the sidebar model while preserving the full workspace path as the copy target.
* fix(workspaces): source owned worktree names from daemon
The daemon owns worktree path semantics, so expose the managed worktree slug and let the sidebar model produce a display-ready label. This keeps path parsing out of the hover-card component while preserving the full path for copying.
* fix(app): preserve worktree labels in replica cache
* feat: make relay opt-in for device pairing
* fix(relay): keep pairing state authoritative
* fix(relay): refresh pairing state across transports
* fix(onboard): honor explicit relay opt-out
* fix(relay): surface transport startup failures
* fix(pairing): gate offers for older daemons
* test(cli): rely on real pairing transport coverage
* fix(cli): report live relay without a pid file
* fix(relay): synchronize config during daemon startup
* fix(relay): preserve mutability for ignored overrides
* fix(cli): use reachable daemon for pairing
* fix(app): redirect invalid pair device routes
* fix(pairing): support remote host device pairing
Keep Pair Device host-relative through the existing settings and daemon RPC flow. Route CLI output through an injected adapter so workflow tests do not mutate process globals.
* fix(pairing): validate pairing destinations
Expose the host-relative Pair Device flow on every supported app platform. Reject a reachable daemon whose identity does not match the requested Paseo home before reading or mutating its pairing state.
* fix(pairing): render QR codes across platforms
Generate SVG text without DOM or canvas and render it through the existing cross-platform SVG stack.
* fix(cli): pair from persisted relay state
Allow explicit relay pairing while the daemon is stopped when relay is already enabled in persisted configuration.
* fix(app): prevent text selection during Android swipes
Gesture interception did not cancel the native view where the touch began, leaving Android's pending text-selection action alive.
* fix(app): restore iOS sidebar close swipes
Fabric could fail to attach close handlers when their target lived inside retained but display-hidden panel content. Keep a registered native gesture host mounted outside the presentation wrapper so close swipes work over nested lists without activating rows.
* fix(app): recover sidebar drags after cancellation
Cancelled native drags could leave a lifted row and the shared outer list locked. Scope terminal cleanup to the owning gesture session and release the lock on cancellation or unmount.
* test(app): clean up partial drag fixtures
The bulk-history cases spawned about 80 Git processes, which could exhaust the per-test timeout on loaded Windows runners. Import each linear history in one Git process while preserving the real repository behavior under test.
* feat(projects): add local icons and colors
* refactor(projects): simplify appearance handling
* fix(projects): serialize appearance saves
* test(app): update project appearance expectations
* test: fix project appearance CI expectations
* refactor(projects): organize appearance code
* fix(server): adopt main's reconcile model in project appearance updates
* fix(projects): preserve host-local appearance targets
* fix(desktop): restore Linux development startup
Ubuntu can block Electron's unprivileged user-namespace fallback while the npm-installed sandbox helper is not SUID. Disable the Chromium process sandbox only in the Linux development runner so packaged desktop policy remains unchanged.
* feat(projects): add custom project icons
Keep URL acquisition in the client so the host daemon only validates uploaded bytes. Project name and icon changes share one adaptive edit sheet.
* refactor(settings): scope projects by host
---------
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
* refactor(claude): derive subagents from the SDK task protocol
Claude Code announces subagent identity and lifecycle on the SDK stream
(task_started, task_updated, task_notification). Paseo consumed only
task_notification and reconstructed the rest from sidechain frames, in two
independent derivations that had drifted apart.
Both paths now translate into one small observation vocabulary and fold it
with one function, so identity and status are derived once. The fold is
stateless: the task protocol is edge-triggered and the store's sticky merge
already accumulates.
- Replay links a subagent through the agent-<id>.meta.json sidecar, which
carries the same Task tool_use id the live stream keys on. The agentId
text scrape stays as a fallback for sessions recorded without it.
- The sidechain tracker keeps mapping frames to timeline items but no longer
owns the descriptor when the task protocol is active, detected from the
session rather than from a version string.
- Adds replay coverage, which did not exist.
Subagent ids are unchanged, so no protocol, store, or client change.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(claude): keep non-subagent tasks out of the track, open the child timeline with its prompt
Backgrounded shell commands announce as task_started with a tool_use_id but
task_type local_bash and no subagent_type, so filtering on the id alone put
`sleep 20` in the subagents track. Discriminate on task_type, falling back to
subagent_type for releases that predate it.
task_started also carries the prompt. Emitting it as the child's first timeline
item means the pane no longer opens mid-conversation, and matches replay, where
the sidechain transcript already begins with the prompt.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(claude): drop status for tasks that were never declared as subagents
task_notification also fires for backgrounded shell commands and carries a
tool_use_id. Routing status off that id readmitted exactly what the declaration
filter rejected, as a descriptor with a status and no identity — rendered in the
track as a nameless "Loading..." row.
Status now routes only through the task_id map populated at declaration.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(app): name a subagent row after its task, not its type
The descriptor carries the subagent type in title and the task in description.
The client collapsed them and rendered only the type, so every child in a
fan-out read "general-purpose". The task is what tells siblings apart, so it
names the row and the type moves to the muted secondary line, which the track
computed but never drew.
Providers that report no task keep the previous behavior.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(claude): label the parent's Task card from the subagent declaration
A backgrounded Task subagent emits no frames carrying parent_tool_use_id, so
the sidechain tracker never runs for one and never supplies the sub_agent
detail. Its card in the parent transcript rendered as a bare "Task".
The declaration already carries the type and the task, so the card is built
from it. For a foreground subagent the tracker still emits the same card
enriched with the child's action log; the two collapse on the shared tool-call
id, so that case is unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(subagents): surface a subagent's model, effort, and cost
Adds model, effort and usage to the provider subagent descriptor, populated
from observations the child itself reports.
- model: read per assistant frame, live and on replay. Never snapshotted, since
a provider can swap models mid-flight on overload or refusal fallback, and
never inherited from the parent.
- effort: replay only. The SDK's assistant message does not carry it and it can
be silently downgraded for the chosen model, so a live value would be a guess.
- usage: task_progress reports tokens and tool count once per tool use, and
task_notification reports the terminal totals. This matters most for
backgrounded subagents, which emit no sidechain frames and would otherwise
show no sign of activity at all.
Status becomes optional on the upsert event: a report that only carries a model
or a cost says nothing about whether the child is still running, and must not
revert a finished one. Usage merges rather than replaces, because duration only
arrives at the end.
Fields are optional on the wire, so an older daemon simply omits them and the
client renders nothing rather than substituting the parent's settings.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(claude): read subagent effort from hooks
Effort appears nowhere on the SDK message stream — verified by scanning every
message type at depth — and the level Paseo requests is not necessarily the one
that runs, since a model that does not support it is silently downgraded.
Hooks are the source. Every hook input carries agent_id and effort.level, the
active level for that turn after any downgrade, and agent_id is the same id
task_started calls task_id, so it routes through the table already built at
declaration. PreToolUse, PostToolUse and SubagentStop are registered purely to
observe; they record what they see and always return empty, so they cannot
affect tool execution or turn control.
Paseo's hooks merge with any the user configured rather than replacing them:
extraClaudeOptions is spread after the base options, so assigning would have
silently stopped effort being observed for anyone with their own hooks.
Verified end to end through ClaudeAgentClient against a real session: a
subagent descriptor now carries model, effort and usage together.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(subagents): show a subagent's runtime facts in its pane, and its cost on replay
The track row carried model, effort and cost; the pane carried none of it, so
opening a subagent showed only its task.
The pane gets a header strip rather than a longer tab subtitle: the subtitle
feeds the width-constrained tab strip, whose job is to name the task. Duration
appears in the pane, which has room, and stays out of the dense row.
Replay had no usage source, so a reopened agent lost its subagents' token and
tool counts permanently — descriptors are in-memory and cleared when an agent
closes. Usage is now derived from the child's own transcript, matching the live
producer exactly: total tokens are the four counters summed on the last
assistant turn (a context reading, not a cumulative sum, which would multiply
the cached prefix by the turn count), and tool uses are tool_use blocks across
assistant entries. Duration is an approximation — the transcript's first entry
is written after the task starts — and is the one field replay cannot match.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs(claude): record the task protocol's undocumented wire facts
The subagent sources depend on Claude Code internals the SDK types do not
explain: which announced tasks are subagents, why a status for an undeclared
task is dropped, why task ids outlive a turn, why effort is only reachable
through hooks, and why a replayed session must skip its grandchildren.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Generalize provider subagent presentation
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
* fix(workspaces): show isolation without checkout delay
* fix(workspaces): trust hydrated project capability
* fix(workspaces): resolve branch before creation
* fix(workspaces): honor invalidated checkout status
* fix(workspaces): preserve isolation while projects load
* fix(workspaces): keep provisional project routes usable
Route-derived projects do not know their worktree capability. Represent that uncertainty at the placement boundary so remembered worktree intent survives hydration without disabling the New Workspace screen indefinitely.
* Switch reasoning, mode, plan, and fast from the Command Center
Extend the Command Center (Cmd-K) with selectors for a focused agent's
Thinking (reasoning effort), access Mode, Plan mode, and Fast mode,
mirroring the model switcher. Typing surfaces tiered "<Setting> › <Value>"
breadcrumb rows with icons and a check on the current value, for both a
running agent and a new draft tab. Rows only appear while typing, so the
default palette is unchanged.
- Thinking and Mode are multi-option selects; Plan mode and Fast mode are
On/Off toggles.
- Plan mode is dual-natured: a permission mode for Claude/Copilot/OpenCode
(excluded from the Mode list, given its own toggle) and the plan_mode
feature for Codex. Fast mode drives the fast_mode feature (model-gated).
- All logic lives in command-center/agent-setting-contributions.ts as pure,
unit-tested builders, with a small icon helper and a hook that registers
the running-agent selectors; the draft registers from its composer tab.
No changes to the command palette component and no protocol changes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Unify Command Center agent controls
* refactor: clarify agent control ownership
* refactor(app): tighten command center agent controls
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
* fix(git): reconcile pull requests after branch changes
Treat persisted change-request metadata as a branch-bound hint and prefer current tracking configuration when it identifies the checked-out branch.
* fix(git): preserve enterprise fork identity
Use branch-bound metadata to supplement the owner when explicit tracking points at the same head on a self-hosted remote.
* fix(git): honor local branch upstreams
Treat Git remote dot with a merge ref as explicit current tracking so stale PR metadata cannot override a local upstream.
* fix(git): own change request branch bindings
Centralize persisted hint validation and rebinding so renames preserve change request identity while switches invalidate it, including normalized legacy Enterprise owners.
* fix(git): preserve PR identity through workspace renames
Resolve the backing worktree root during rename without adding a Git call, and retain fork owner identity from explicit same-name tracking configuration.
* fix(git): trust current enterprise fork identity
Explicit tracking now derives repository identity from the current remote path, so stale checkout metadata cannot override a repointed fork.
* fix(git): recognize enterprise repository aliases
GitHub repository identity is case-insensitive. Normalize equality checks so differently cased aliases cannot turn base tracking into a fork lookup.
* fix(git): reject ownerless legacy fork matches
Owner-qualified legacy hints only belong to their historical owner-prefixed branch names. A later ownerless branch reusing the same head must resolve from current Git state instead.
* fix(git): keep fork feature lookups local
A feature branch may track the upstream base branch for ancestry even when origin is a fork. Only treat that cross-repository base ref as PR identity when the local branch name identifies the tracked head.
* fix(app): preserve project selection across hosts
Rendered project identity and persisted equivalence were conflated after duplicate-project handling. Keep them separate so host changes resolve selectable equivalents deterministically without resetting legacy sidebar identity.
* fix(app): restore selected clone after host round trip
Keep manual origin and current destination placements separately so switching back restores the chosen checkout while archive gaps preserve the active host selection.
* fix(app): key status icons by project view
* fix(app): preserve origin during archive gaps
* feat: show project icons in sidebar status view
* test(sidebar): keep status icon regression active
The ten-second mock can finish during app startup, removing the Working bucket before the icon assertion. Keep the seeded workspace active for the full scenario.
---------
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
* fix(server): ignore OpenCode snapshot diffs when starting autonomous turns
OpenCode stamps its filesystem-snapshot diff onto the user message after
the turn goes idle. Since #2258 Paseo treats an unknown idle user
message.updated as the start of an autonomous turn, so that late metadata
write opened a turn no provider-idle boundary would ever close. The agent
stayed falsely running until the next submission interrupted it.
Reject user message.updated events carrying summary.diffs. Real prompts
never carry snapshot diffs, so genuine autonomous turns still start, and
filesystem snapshots stay enabled for undo and revert.
* fix(server): trust OpenCode busy status for autonomous activity
Mutable message records can receive metadata after execution ends. Treat the provider's busy status as the activity boundary and buffer preceding content until that boundary arrives.
* test(server): cover autonomous provider activity
* fix(server): reconcile OpenCode wakes from busy status
# Conflicts:
# packages/server/src/server/agent/providers/opencode-agent.ts
* fix(server): preserve background activity boundary after stops
* fix(server): keep idle events outside background activity
Only provider execution status proves a new activity boundary. Reconcile adopted sessions once at subscription time instead of retaining unowned idle events for a future run.
* fix(server): order adopted session reconciliation
Live runner-status events supersede an in-flight status snapshot. Treat busy and retry as one active provider state.
* test(server): remove misleading snapshot gate
---------
Co-authored-by: Des Flynn <des@desflynn.com>
* fix(app): prevent transient file conflict callouts
Watcher versions are candidates until a matching disk read settles them. The editor now classifies the settled bytes once, preserving dirty buffers and keeping reload content paired with its revision.
* fix(app): preserve external BOM changes
* feat(settings): choose which orchestration skills to install
The skills row installed the whole bundle or nothing. A settings control
next to it now opens a sheet where you keep every bundled skill or pick a
subset, and saving converges ~/.agents, ~/.claude, and ~/.codex to it.
"All skills" reads the bundle directory instead of a hardcoded catalog, so
a skill added in a later release installs itself without another code
change. Retired bundle names stay in a separate cleanup list so old copies
on disk are still removed but never become selectable.
The selection persists under Electron user data, separate from install
state, so uninstalling does not erase the choice — a later install brings
back exactly the chosen set. Installs with no saved selection read as
"all", which is what they already had.
* Restore skill selection after failed save
* fix(settings): don't save a skill selection that never reached disk
A failed save wrote the preference, converged, then wrote the old preference
back. That leaves a window where a selection the user was told did not save
is the one on disk, and a crash inside it makes the window permanent.
Converge first and persist only on success — nothing to roll back, and a
failed save now leaves disk and preference exactly as Cancel would.
Resolving the bundle path when the command handlers are wired also broke
daemon-manager's tests: the packaged branch reads process.resourcesPath,
which is undefined outside Electron. Resolve targets per command instead —
registering handlers should not touch the environment.
* fix(settings): make skills convergence single-writer and confirm removals
Startup auto-update and the settings commands were separate writers over the
same skill directories. Either could apply a plan computed from a selection the
other had already replaced — narrow the selection while startup is repairing
drift and the repair puts the removed skill back. There is now one controller
that owns scan, apply, and persist, and every entry point queues through it, so
concurrent callers see a consistent result instead of an interleaved one.
Save also had no recovery once it had converged: if the selection could not be
committed afterwards, disk was changed while the preference still said the old
thing, and the next startup silently undid the change. Save now puts the
managed directories back to what the committed selection describes before it
reports the failure, so a failed Save lands where Cancel would.
Deselecting a skill deletes its directory in all three agent homes, including
files the user put inside it. That is now confirmed like every other
destructive action, naming the skills and the directories. Adding skills or
saving an unchanged selection still saves without a prompt, and a skill the
host has not installed yet deletes nothing so it does not prompt either.
* fix(settings): confirm removal for skills installed in only some agent homes
The removal confirmation read an `add` op as "not on disk yet". An `add` op
means "missing from at least one target", so a skill sitting in ~/.claude but
not the other two looked absent, and deselecting it deleted that directory —
with anything the user had put inside it — without asking.
The status snapshot now carries the installed skill names directly, taken from
union presence across the three agent homes, and the confirmation reads that.
`ops` keeps its one meaning.
A host that predates the field cannot say what is on disk, so the app bridge
assumes the saved selection is installed. Asking about a directory that turns
out to be absent is harmless; the reverse is not.
* fix(settings): warn about every skill directory a save deletes
The confirmation asked "was this selected before and is it selected now",
which is a different question from the one the host answers. The host
converges to the desired set and deletes every managed directory outside it,
so a skill on disk that the saved preference never mentioned — reinstalled by
hand, restored by a sync, left behind by drift — was deleted with no warning,
user files included.
The predicate now mirrors the host rule: everything installed that the draft
will not keep, regardless of what was saved before. It moved out of the sheet
into its own module so the rule has one owner and can be tested directly.
Saving with nothing to delete still does not prompt.
* fix(settings): a failed save keeps files the user added inside a skill
Save converges before it commits, and convergence deletes whole skill
directories. When the commit then failed, the recovery re-installed the
committed selection — which rebuilds the bundled files and nothing else, so
notes, hooks, and anything else the user had put inside a deselected skill
were gone for good. Cancel does not do that, and a failed save is supposed to
land where Cancel would.
Save now runs as a transaction. Before converging it stages a copy of every
managed skill directory across the three agent homes, next to the tree so the
copy stays on one filesystem. Committing drops the staging; failing anywhere
puts each directory back exactly as it was, deletes the ones the candidate had
created, and then drops the staging. Staging left by a process that died
mid-save is cleaned up by the next one.
The controller only sees begin/commit/rollback; the filesystem work lives in
skills-transaction.ts.
* fix(settings): make skill removal confirmation authoritative
* fix(settings): verify skill convergence before commit
* fix(settings): preserve concurrent skill edits on rollback
* fix(settings): preserve staged skill metadata
* fix(settings): stage skill deletions atomically
* fix(settings): stage removals per filesystem
* fix(settings): restore staged skill symlinks
* fix(settings): canonicalize skill transaction roots
* fix(settings): keep automatic skill updates non-destructive
* fix(settings): report committed skill saves accurately
* fix(settings): keep skill updates non-destructive
* fix(settings): route skill removals through selection
* fix(settings): close skill selection edge cases
* fix(settings): preserve skill data during maintenance
* fix(settings): hide unavailable skill install
* fix(desktop): keep install policy at command boundary
* fix(desktop): restore staged skill directories atomically
* test(app): retry transient skills sandbox cleanup
* fix(desktop): recover externally deleted skill stages
* fix(desktop): preserve conflicting skill rollback data
* fix(desktop): generalize skill rollback conflicts
* fix(desktop): preserve skill rollback collisions
* fix(desktop): recover quarantined skill backups
* fix(app): inset adaptive sheet content on compact
The compact Choose skills sheet rendered edge to edge: the All skills card
touched both screen edges, the section label was glued to it, and the skill
list ran to the edges too. Desktop was correct.
AdaptiveModalSheet expressed its content inset through
BottomSheetScrollView.contentContainerStyle. That is a library prop, not the
style prop Unistyles registers, and gorhom's scroller was never rewritten by
the Babel plugin, so on web the emitted class was never applied and the
padding and gap resolved to nothing. The desktop card uses a real ScrollView,
which Unistyles does track, which is why only compact was affected.
The inset now lives on a View the sheet owns, rendered by every presentation,
so one definition covers compact and desktop and a scroller only ever gets
theme-free layout. The caller-facing prop is contentStyle, since it no longer
lands on a scroll content container.
Choose skills drops its ad-hoc label for SettingsSection, so the label carries
the app's section rhythm instead of sitting equidistant between two cards.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(desktop): prevent skill rollback data loss
Preserve externally replaced directory trees during recovery and reject managed child symlinks before synchronization can write outside a skill directory.
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* fix(opencode): prevent late aborts from canceling replacement turns
OpenCode aborts are session-scoped, so provider idle alone does not make a session safe to reuse. Keep abort retries owned by the stopping turn and require both abort settlement and provider idle before dispatching the next runner operation.
* fix(opencode): retain the stop barrier through provider idle
Keep the stopping operation authoritative until both its session-scoped abort and provider-idle evidence settle, including stale idle-session interrupts. Also keep the real drift fixture credential-allowlisted and wait for command output pipes to drain.
* test(opencode): launch Windows fixture shims safely
* fix(opencode): recover stop observation safely
* fix(opencode): preserve autonomous wake through stop
* fix(opencode): retain deferred wake completion
* fix(opencode): serialize stop boundary replay
* test(opencode): use shared real e2e lane
* fix(opencode): own stops started during replay
* fix(opencode): retry stop observation on demand
* fix(opencode): follow replacement stop boundaries
* fix(opencode): retain wakes during stop recovery
* fix(opencode): hold replay turns behind boundary
* fix(opencode): filter canceled reconnect output
* fix(opencode): bind status evidence to stop
* fix(opencode): acknowledge owned cancellation
* fix(opencode): reconcile stale busy evidence
* fix(opencode): bind replay terminals to wakes
* refactor(opencode): own the stop boundary in one explicit state
The run boundary had grown into a mutable flag bag plus a raw-event
buffer that was replayed and re-classified after the fact. Each new
interleaving needed another predicate, and the classification kept
losing autonomous wakes.
Restore stopping as a turn-state variant that owns exactly two things:
the session-scoped abort it issued (with its single owned retry) and the
canceled run's terminal. Runner operations wait on both; a failed abort
fails closed. Provider status reconciliation now ends the stop through
the same transition an SSE terminal takes.
The canceled run's residue is suppressed only up to that terminal.
Events published afterwards are a new run by construction and take the
normal live path, so a wake no longer waits for abort settlement and
cannot be dropped by a retention heuristic.
* fix(opencode): emit cancellation before adopting the next run
The acknowledgement ran off a promise combinator, so a wake adopted from
the event right after the terminal could reach subscribers before the
canceled turn's turn_canceled. Acknowledge synchronously from whichever
proof lands first instead.
* docs(opencode): attach the stop doc comment to its interface
* fix(opencode): retry the stop in progress instead of opening a second
Pressing Stop again after an abort failed built a detached boundary: its
fresh abort could succeed while the canceled turn kept waiting for an
acknowledgement, so the daemon sat out the full cancel timeout before
force-canceling. A repeated Stop now replaces the abort owned by the
stop already in progress, which is what carries the acknowledgement and
what lifts the fail-closed gate.
* fix(opencode): accumulate every abort a stop has issued
A repeated Stop replaced the stop's abort settlement instead of adding
to it, so a first request still in flight — the slow-teardown case the
cancel cap exists for — no longer held the gate. The replacement could
start while that request was live and get cancelled by it, which is the
race this boundary exists to close. Reuse now waits for every abort the
stop issued, while only the newest can hold the gate closed.
* fix(opencode): scope abort settlement to the session, not the stop
Abort is session-scoped, so its settlement has to be too. Tracking it on
the stop meant a new stop boundary silently dropped an older request
that was still in flight, and that request could still cancel whatever
run started next. The session now accumulates every abort it has issued;
the stop is left owning only the run it is stopping.
* fix(opencode): recheck the abort gate after waiting on it
A waiting prompt read the settlement once, so a Stop issued while it
waited was invisible to it: the older abort would settle, the prompt
would start, and the newer one could still cancel it. Drain until the
settlement observed is the current one.
* fix(app): distinguish changed and deleted files
Successful reads now reconcile transient missing events without letting stale
cached data mask a deletion. File notices expose only actions valid for the
current buffer state.
* fix(app): preserve file state ordering
* fix(app): allow retrying file checks
* fix(app): clear unchanged file conflicts
* fix(app): keep file conflict recovery consistent
Own disk observation and read ordering in one model so stale reads and failed subscriptions cannot produce contradictory actions.
* fix(app): allow retrying file preview errors
Preserve daemon read errors and expose retry for initial and non-editable file loads.
Issues are bug-only now. The feature-request template is gone, requests
opened as issues get closed automatically, and feature talk moves to
product discussions on GitHub Discussions and Discord. "Product
discussion" is the term everywhere, recorded in the glossary.
Contributing states the QA evidence bar and links out instead of
teaching; docs/qa.md carries the depth. The protocol back-compat rules
moved out of CLAUDE.md into docs/protocol-compatibility.md, since that
constraint is invisible in development where both sides always run the
same version.
CLAUDE.md gains doc-writing and voice rules to keep docs from
accumulating appended paragraphs. design.md gains an alignment section
(glyph rails) and a layout-shift rule.
Restore the Linux startup behavior that shipped in 0.2.3 by reverting the generalized sandbox detection from #2439.
Co-authored-by: tang <tang@tang-zai>
* fix(projects): restore cross-host grouping
Random host-local project IDs replaced the remote-derived value that had also served as grouping identity. Persist a separate opaque group key and let normal boot reconciliation backfill older records without a migration.
* fix(projects): keep ambiguous clones separate
A shared remote can identify a project across hosts, but it cannot distinguish two independent clones on one host. Treat those keys as ambiguous so host-local project IDs remain separate.
* fix(projects): preserve host-local project identity
* fix(projects): strengthen grouping identity
* test(app): update project host shape contract
* fix(projects): preserve selected host identity
* fix(projects): close grouped routing gaps
* fix(projects): stabilize legacy subproject grouping
* fix(projects): preserve selected project identity
* fix(projects): isolate legacy host identity
* fix(projects): preserve symlinked bootstrap roots
* fix(projects): reconcile grouped custom names
* fix(projects): stabilize settings and path identity
* fix(projects): normalize default remote ports
* fix(projects): harden host and remote identity
* fix(projects): preserve host-scoped actions
* fix(projects): preserve remote path semantics
* fix(projects): model SCP remote semantics
* fix(projects): keep settings on routed host
* fix(projects): preserve SSH URL path semantics
* fix(projects): separate identity from presentation
* test(projects): use grouped identity in UI specs
* fix(projects): harden grouped project routing
* test(projects): expect framed settings route
* fix(projects): preserve exact placement identity
* fix(projects): harden grouping identity and routing
* fix(projects): restore project key as grouping identity
Random host-local project IDs had replaced the persisted cross-host value carried by projectKey. Keep projectId for routing while reconciliation owns and persists projectKey for grouping.
* fix(projects): prevent project key collisions
Preserve platform path semantics and generic remote transports in cross-host keys, and compare legacy checkout roots using filesystem equivalence before materializing ownership.
* test(projects): assert host-scoped project keys
* fix(projects): preserve project key compatibility
Resolve legacy workspace project references at the daemon boundary while keeping projectKey as the cross-host identity. Complete generic SSH normalization without collapsing distinct repositories.
* fix(projects): harden project reference identity
* fix(projects): retain archived reference errors
* fix(projects): scope legacy paths to hosts
* test(app): model persisted project keys
* fix(projects): retain absolute scp users
* fix(projects): resolve legacy mutation aliases
* fix(projects): preserve conservative identity boundaries
* fix(projects): preserve legacy placement identity
* test(projects): assert legacy placement identity
* test(projects): expect canonical Windows keys
* fix(projects): separate bootstrap identity fields
* fix(projects): scope legacy paths by host
* fix(projects): stabilize opaque project identity
* fix(projects): preserve remaining opaque identities
* fix(projects): classify forward-slash UNC roots
* fix(projects): preserve opaque selection keys
* fix(projects): frame host-local project keys
* fix(projects): canonicalize framed identities
* test(projects): isolate repeated remote fixtures
* fix(projects): keep project key as UI identity
* fix(projects): reconcile hydrated project references
* fix(projects): preserve archived project identity
* refactor(projects): group projects from project records, not workspaces
Cross-host grouping had been built by hanging a projectKey off every
workspace descriptor. That scoped the key to the wrong entity: the app
then had to reconstruct project identity per workspace in six modules,
which forced projectKey fallbacks into RPC arguments and a server-side
resolver that turned grouping keys back into project ids.
A workspace has a projectId. The key belongs to the project, and
grouping is a display concern, so the daemon now publishes projects
over project.list plus the existing project.update channel and the app
groups them in one place. Projects are addressed by host-local
projectId everywhere, including the settings route.
Projects persisted without a key are backfilled by ordinary boot
reconciliation, with no migration. Old daemons advertise no projectList
capability and simply do not group.
* test(projects): seed projects alongside workspaces in fixtures
Sidebar fixtures seeded workspaces without their projects, which the
daemon cannot produce now that projects arrive on their own channel, so
nothing rendered. Adds a seeding helper that supplies both.
Also drops the provisioning test for accepting a project key where a
project id belongs; that resolver was removed deliberately.
* fix(projects): keep derived project identity stable
Four gaps left by shrinking the key derivation:
Local keys used a bare path resolve, so one directory reached by a
different spelling produced a different key and reset grouped identity.
Windows CI caught this with the 8.3 and long-name forms of one temp
path. Canonicalize through the shared realpath-aware normalization.
Subproject display names lost their selected-path input, so every
subproject of a monorepo rendered under the same remote-derived name.
Reconciliation only ever backfilled a missing key, so a project whose
remote changed stayed grouped with hosts on the old remote.
Project settings kept a disconnected host selected and rendered a dead
end with no way back to an editable host.
* fix(projects): read scheme remotes as URLs, not scp form
An `ssh://user@host:22/org/repo` remote matched the scp-form pattern
first, because `[^@]+` happily eats `ssh://git`, so the port landed in
the path and the project keyed as `remote:host/22/org/repo`. Two
spellings of one remote therefore never grouped.
Try scp form only when there is no scheme, and drop protocol-default
ports so an explicitly written `:22` matches the same remote without it.
* fix: reject oversized checkout diffs
* fix(diff): keep large previews from disconnecting clients
The physical socket limit is an OOM backstop, not a frame-size contract. Bound structured diff construction incrementally and report oversized previews without closing the client's socket.
* fix(app): preserve oversized diff state
* fix(diff): stay within relay frame limit
---------
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
* test(server): make stderr timeout coverage deterministic
The timeout assertion raced Windows child-process scheduling and stderr pipe delivery. Buffer stderr before starting the timed request so the test isolates the transport behavior it intends to verify.
* test(server): synchronize stderr timeout test in parent
Use the injected process adapter to populate stderr through the parent-side stream before starting the timeout. This removes cross-pipe ordering from the test.
* fix(github): support repository lists from older gh versions
GitHub CLI versions before 2.28 reject the visibility JSON field before returning repository data. Retry that specific failure with the older isPrivate field while preserving exact visibility on newer clients.
* fix(github): stop requesting repository visibility
Repository cloning does not use visibility. Leave the released response field optional and keep repository discovery on JSON fields supported by older GitHub CLI versions.
* fix(github): keep the visibility enum on the wire, derive it from isPrivate
Restores the required visibility enum in the search-repositories response
schema. Omitting it broke old clients, which reject the whole response.
The daemon still never asks gh for visibility -- that JSON field does not
exist before gh 2.28 and Debian Bookworm ships 2.23. It asks for isPrivate,
supported on every version, and maps it to public/private.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* fix(explorer): prevent persisted trees from crashing startup
Persisted expansion restored every directory and recursively flattened the resulting tree during render. Limit automatic restoration to five rendered directory levels and flatten iteratively so deep or broad workspaces cannot exhaust the JavaScript stack.
* fix(explorer): preserve expansion changes during restore
Reconcile completed restoration against the current expansion set atomically so folder interactions made while directory requests are in flight are not overwritten.
* fix(explorer): show hidden files before restoration
* fix(explorer): apply directory toggles atomically
* fix: preserve context window data when turn_completed lacks usage
Merge incoming usage into agent.lastUsage instead of replacing it
wholesale. Some providers (Claude autonomous turns, OpenCode) emit
turn_completed without a usage payload, which previously wiped the
contextWindowMaxTokens/contextWindowUsedTokens accumulated during
streaming usage_updated events. The result was the context window
meter disappearing from the composer once the agent finished work.
* test(server): preserve usage after autonomous completion
---------
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
* Add npm release track invariant and beta dist-tag procedure
* Revert "Keep older chat history and image previews stable (#2490)"
The timeline optimistic/pagination rework needs more work than main releases can wait for. Reverted together with #2484; both are reapplied on integration/timeline-optimistic-rework.
* Revert "Stop completed turns from appearing stuck (#2484)"
See the #2490 revert. #2490 built on this submission-authority change, so the two revert and reapply as a pair.
* fix(server): keep idle agents resident
Remove time-based runtime collection so background work and subsequent prompts are not destroyed during idle periods. Runtimes now close only through explicit lifecycle actions.
* fix(server): preserve explicit close coordination
* fix(server): support partial agent manager adapters
* fix(app): keep timeline history and previews stable
Treat persisted timeline replicas as display-only so authoritative pagination always comes from the daemon. Require renewed user intent between older-history pages unless the viewport genuinely remains at the history edge.
Model assistant image acquisition as loading, loaded, or failed so recreating a preview URL cannot be rendered as an error.
* fix(app): keep file image previews current
* fix(app): stabilize history pagination edges
* fix(app): recover transient timeline loads
* fix(app): retain assistant image previews
* Stabilize history pagination lifecycle
* Stabilize history settlement and image previews
* Retry file images after reconnect
* Keep active previews and pagination requests alive
* Replace image hook tests with typed ports
* Integrate timeline hydration with submission authority
* Harden pre-hydration timeline reconciliation
* Make rewind E2E use real scroll intent
* Preserve live timeline state through hydration
* Keep mounted image attachments retained
* Protect preview persistence and lifecycle hydration
* Preserve idless live assistant continuations
* Preserve timeline rows across delayed hydration
* test(app): cover real assistant image files
* Preserve assistant tool ordering during hydration
* fix(server): replace stale plan approvals
Synthetic plan approvals accumulated across planning turns because each proposal used a new permission ID. Dismiss the current proposal only after a follow-up prompt is accepted, and enforce one pending proposal when a newer plan arrives.
* fix(server): close plan approval submission race
Deactivate the existing proposal before starting a revision so it cannot be implemented concurrently. Restore it when submission fails, while preserving any newer approval emitted during the request.
* fix(server): deactivate plans before prompt setup
Start the plan-dismissal transaction before any asynchronous prompt setup so a stale proposal cannot be implemented from another client while the revision is preparing.
* fix(server): make prompt dismissal final
Treat prompt submission as the dismissal event instead of compensating after failures. This avoids resurrecting stale plans across ambiguous provider outcomes and manager failure cleanup.
* feat(app): dismiss the chat keyboard on a fast upward flick
Scrolling the history to read earlier messages left the keyboard up, so the
visible transcript stayed cramped and the keyboard had to be closed by hand
first — two steps for what should be one.
React Native's keyboardDismissMode cannot express the wanted behaviour:
"on-drag" fires on the first pixel and kills the keyboard on any peek-scroll,
and "interactive" is broken on inverted lists. So the gesture is measured
here: samples are taken from the scroll events and, at release, the speed over
the drag's final stretch decides. Measuring at release rather than averaging
the whole drag is what keeps a fast but controlled read-scroll from counting
as a flick, since such a gesture decelerates before the finger lifts.
Timestamps and offsets come from the events, never from a clock: with a busy
JS thread the callbacks arrive in a burst long after the gesture, and
wall-clock spacing then reads a calm scroll as a flick. The release offset
comes from the end-drag event for the same reason — the last onScroll can be
stale by the time a short flick lands.
Android needs both the blur and the dismiss. Dismissing alone leaves the
input focused and the keyboard inset applied, so the layout stays shifted
with an empty gap where the keyboard was; blurring alone releases focus but
leaves the IME on screen.
Verified on an Android device with a Release build: a slow drag and a
0.6 dp/ms scroll keep the keyboard, a 2.9 dp/ms flick dismisses it and the
composer settles back with no leftover gap. iOS was exercised by hand on
device only, without automated coverage of the gesture itself.
* refactor(app): isolate keyboard flick dismissal
* fix(app): isolate keyboard shift context
---------
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
* perf(ci): reduce server test latency
Server test files use isolated resources, so they do not require suite-wide serialization.
* fix(ci): scope server test parallelism to unit suite
Keep real-provider and local-resource suites serialized because they may share user configuration and account limits.
* fix(terminal): isolate zsh runtimes by process
Prevent concurrent daemon and test processes from deleting or replacing shell integration files used by another process.
* fix(ci): preserve required matrix check names
GitHub evaluates job conditions before expanding a matrix. Expand active matrices first and gate their expensive steps so required check contexts are always reported. Allow superseded runs to cancel while retaining fail-open path detection.
* perf(ci): skip unaffected test jobs
Keep required checks present as skipped jobs and run the full matrix whenever change detection cannot produce a trustworthy result.
* fix(ci): harden change-based job gating
Include shared build inputs in packaged desktop smoke selection and pin the path filter action that controls job execution.
* fix(ci): run CLI checks for Nix packaging changes
The CLI supervision regression suite reads the Nix package definition directly, so include that external dependency in its path filter.
* fix(ci): track external test inputs
Select server and CLI suites when their narrowly scoped cross-package fixtures and source assertions change.
* fix(ci): track server test CLI imports
Run server tests for CLI source changes because the Hub relationship harness executes the CLI command graph directly.
* fix(ci): pin gating checkout action
Keep every third-party action that controls change detection pinned to a verified commit SHA.
* fix(quota): restore Grok Settings usage for current CLI auth/billing
Grok CLI no longer stores a top-level access_token or usage.creditUsage.
Read nested auth key tokens and config.used.val so Settings → Usage
shows monthly credits again. Keep legacy shapes and env tokens working.
Fixes#2352
* fix(quota): make Grok auth-file tests work on Windows
Inject homeDir into GrokQuotaProvider like Kimi so nested
~/.grok/auth.json tests do not depend on os.homedir() which
ignores $HOME on Windows (USERPROFILE).
* feat(server): support symlink worktree includes
* refactor(server): simplify worktree include handling
* fix(server): constrain worktree include traversal
* fix(server): clean up failed worktree branches
* fix(server): skip missing worktree include entries
* fix(server): make worktree includes best effort
* fix(server): skip failed worktree includes
* fix(server): harden worktree include planning
* fix(server): preserve reused worktree result shape
Materialization reports describe one creation attempt, not durable worktree identity. Carry them beside the worktree so newly created and reused results keep the same stable shape.
* fix(server): harden worktree include boundaries
Replace directory snapshots exactly, keep canonical Git metadata protected, report recovery skips, and only roll back branches owned before worktree creation.
* fix(server): follow safe include aliases
Traverse canonical in-checkout directory links during glob planning and treat coded revalidation failures as per-entry skips.
* fix(server): make include preflight race safe
Create fetched checkout refs atomically, retain overlapping copy entries for independent fallback, and protect the full managed-worktree base.
* fix(server): preserve staged recovery state
Validate completed directory snapshots, retain backups after failed restoration, and derive atomic ref guards from the repository object ID width.
* fix(server): preserve partial include progress
Keep safe glob matches, enforce recursive-directory types, validate staged roots, and retain OID guards through rollback.
---------
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
* feat(app): open project folder from project context menu (#2487)
* refactor(app): give file manager action a home
---------
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
* feat(app): add ⌘P shortcut to switch project on New Workspace screen
Opens the existing project picker with its search focused so the project
can be switched from the keyboard (type + Enter) instead of clicking the
badge and then the project.
Wires a new "workspace.project.pick" action through the standard keyboard
pipeline (binding -> route passthrough -> dispatcher), handled by a
screen-scoped handler on the New Workspace screen that is only registered
while the screen is mounted and there are projects to pick. Because
preventDefault only fires when a handler handles the key, ⌘P/Ctrl+P still
triggers native print everywhere else. Adds a Settings -> Shortcuts help
row (rebindable) and the "Switch project" label across all locales.
* test(app): cover project picker shortcut
---------
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
"Open in browser" links for a self-hosted forge served on a non-standard
port (e.g. Forgejo/Gitea on :60443) dropped the port, producing
https://host/owner/repo/... instead of https://host:60443/owner/repo/...,
which 404s or hits the wrong service.
parseGitRemoteLocation discarded parsed.port (GitRemoteLocation had no
port field), and buildForgeBranchTreeUrl / buildForgeBlobUrl rebuilt the
origin from the portless host. Preserve the port on GitRemoteLocation and
reattach it in the web-URL builders, only for self-hosted http(s) origins
(an SSH port isn't the web port; a canonicalized cloud host uses the
default port). Host-identity matching (forge detection, cloud-host checks)
stays port-agnostic.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
`worktree.setup` ran two POSIX-only command strings, but lifecycle commands
go through PowerShell on Windows, so worktree creation failed at:
PASEO_DEV_MANAGED_HOME=1 PASEO_DEV_SEED_HOME=... ./scripts/dev-home.sh
-> PASEO_DEV_MANAGED_HOME=1 : The term ... is not recognized
PowerShell has no `VAR=value cmd` prefix syntax. The `cp` entry was broken the
same way: `$PASEO_SOURCE_CHECKOUT_PATH` is an undefined *PowerShell* variable,
not an env var, so it expanded to empty and the copy resolved to
`/packages/server/.env`.
Neither entry can be expressed portably in a single shell string, and `bash` is
not guaranteed on Windows, so move both steps into a Node script that reads its
inputs from `process.env` — matching the existing
`node ./scripts/seed-ios-native-cache.mjs` entry. One code path, no platform
branching.
`scripts/dev-home.sh` is unchanged and still sourced by the bash service
scripts; only the setup-time seeding is ported.
One behavior change: a missing `packages/server/.env` in the source checkout is
now skipped with a log instead of aborting setup. It is untracked local config,
and the old `cp` hard-failed worktree creation for anyone without one.
Co-authored-by: ABorakati <ABorakati@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* fix(app): stop completed turns from appearing stuck
Turn activity was inferred from a user-message row flag, so a stale duplicate row could keep the working footer active after the turn ended. Track submission lifecycle separately and route every send path through the shared submission flow.
* fix(app): guard pending message identity
* test(app): measure submission layout independent of scroll
* fix(app): keep submitted messages consistent through reconnects
Track each in-flight send until its own RPC establishes acceptance, and keep canonical timeline placement authoritative. Restore legacy cached rewind IDs during cache deserialization.
* fix(app): close canonical submission races
* test(app): cover canonical submission races in browser
* fix(app): keep replacement submissions authoritative
Keep current pending rows out of legacy cache migration and leave ambiguous terminal lifecycle events to the daemon snapshot. This prevents fabricated rewind identities and stale completion events from marking replacement turns idle.
* fix(app): keep submitted messages stable across sync
Give submission transport and canonical timeline ingestion separate authority. Preserve every unresolved local send across replacement, reconcile provider identity once, and bridge RPC acceptance to authoritative running state without deriving lifecycle from timeline rows.
* fix(app): settle submissions in either acknowledgement order
Complete submission transactions when RPC and provider acknowledgement arrive in either order. Cache only transaction-owned local rows as transient data, preserve canonical ID-less prompts, and invalidate ambiguous legacy display caches instead of inventing provider identity.
* fix(app): keep agent visible during history handoff
Running and terminal updates could clear create continuity before the initial authoritative timeline arrived, leaving a streaming agent behind the loading screen. End the handoff only when authoritative history is applied.
* fix(app): settle attachment-only submissions
Canonical providers can acknowledge image-only prompts with empty text. Reconcile those events by client identity without rendering a blank canonical row.
* fix(agent): settle out-of-band message submissions
Accepted commands that do not allocate a foreground turn previously had no canonical user acknowledgement. Record the command before its handler runs so submission state and reconnect history converge through the normal timeline producer.
* fix(app): settle out-of-band submissions compatibly
* fix(app): preserve canonical prompt order
* test(app): keep workspace status check timing-independent
The workspace-status scenario asserted footer settlement before initial agent creation had necessarily completed. The dedicated draft-handoff coverage owns that lifecycle contract.
* fix(app): keep provider settings above model selector
Desktop web comboboxes used the browser top layer, so ordinary modal portals could not cover them. Keep web overlays in one relative layer stack while preserving native bottom-sheet stacking.
* fix(app): route overlay keyboard ownership
* fix(app): preserve nested overlay ownership
* fix(app): route command center overlay input
* fix(app): preserve global overlay ancestry
* fix(app): register global dialog hosts
* fix(files): keep large downloads connected
Large file reads were emitted as one frame, crossing the physical socket high-water mark and terminating otherwise healthy connections. Stream bounded chunks from one file handle and pace each transfer by its own send completion without globally queueing unrelated traffic.
* fix(files): keep paced relay transfers bounded
Carry send completion through encryption to the physical WebSocket callback, cap growing files at their advertised size, and preserve text classification when a UTF-8 code point crosses the sample boundary.
* fix(files): reject changing transfer snapshots
Abort when a file shrinks below its advertised size, finalize UTF-8 validation for complete samples, and let the physical relay socket remain the sole authority for queued-byte accounting.
* fix(files): validate metadata before transfer
Keep the daemon handshake pending until the ready frame is physically sent, and classify file content with a bounded full-handle scan so streaming preserves the previous binary/text behavior.
* fix(files): detect changing transfer sources
* fix(files): enforce transfer snapshot integrity
* fix(chat): load older timeline history consistently
Pagination depended on scroll events, so short or compacted initial pages could never request older history. Reevaluate edge visibility as layout and history change, and standardize loading indicators on the canonical app spinner.
* fix(chat): keep older history loading reliably
Use authoritative timeline cursor progress so merged rows cannot stall pagination. Wait for bottom anchoring before evaluating the history edge, and re-arm retries on a fresh upward gesture without automatic failure loops.
* fix(server): keep active agent trees resident
Idle collection treated an inactive parent turn as process quiescence even when descendant work was still running. Protect managed and provider-owned subagent trees, carry descendant activity into the idle window, and use a conservative 30-minute fallback.
* fix(server): close idle collection races
Retain managed ancestry across closed intermediates, refresh descendant state after each awaited close, and invalidate running provider subagents when their runtime is terminated.
* fix(server): serialize agent tree collection
Keep ancestors resident until managed descendants are collected, serialize descendant registration with idle collection, and preserve provider subagent state across hot reloads.
* fix(server): retain closed descendant activity
Carry persisted descendant activity through runtime closure, avoid error children pinning parents, and cancel stale provider children when reload replacement aborts.
* test(server): make descendant expiry deterministic
* fix(server): scope idle child protection
* fix(server): tighten idle tree coordination
* fix(server): reduce aggressive idle cleanup
Keep the mitigation conservative: extend the idle timeout without inferring tree liveness from incomplete lifecycle signals.
* fix(server): keep parents alive during child work
Idle cleanup now respects running managed and provider-native children while retaining a conservative 30-minute fallback.
* fix(server): clear running child state on close
* fix(server): drain child events before close
CodeMirror uses a contenteditable surface, which the split-pane focus filter treated as exempt. Let editor interactions claim pane focus just like composer text inputs do.
Quitting the desktop app now shuts down the daemon it started, so
"restart the app" is a complete reset users can act on without first
learning what a daemon is. The old default kept it alive; the toggle
under Settings > Host still opts back in.
Existing installs already persisted `keepRunningAfterQuit: true` from
the old default, so a new default alone would only reach fresh installs.
A one-time migration resets it, and an explicit toggle afterwards
persists the migration flag and is never overridden again.
Only a desktop-managed daemon is stopped — one started with
`paseo daemon start` is left alone.
* fix(claude): select the correct Opus 5 context window
Bare Opus 5 IDs resolve to 200K behind third-party gateways. Expose explicit context variants and require a Claude Code version that recognizes the model.
* fix(claude): add Fable and Sonnet context variants
* test(cli): expect Claude context variants
* test(cli): compose Claude catalog expectations
* fix(claude): preserve catalog on version probe failure
* fix(claude): preserve parsed context variants
* chore(release): cut 0.2.2
* feat(server): control Hub execution lifecycle
* fix(server): archive only execution-owned worktrees
* fix(server): serialize Hub execution control after create
* test(server): compare Hub worktree paths canonically
* fix(server): make absent Hub controls idempotent
A Hub can own an execution before agent creation materializes. Treating an absent daemon-scoped record as already stopped or archived lets finality complete without revealing or affecting another daemon's execution.
* fix: prevent Shift+Tab from changing a backgrounded agent's mode
The New Workspace mode-cycle shortcut (Shift+Tab) could reach a
still-mounted background agent's mode control and silently change that
running agent's execution mode — including into a permissive/bypass
mode — with no feedback in the New Workspace UI.
Root cause is in useKeyboardActionHandler: it re-registered its handler
on every render (a fresh inline `actions` array plus a changing `handle`
identity). The dispatcher runs the most-recently-registered matching
handler first, so a frequently re-rendering control climbed ahead of the
active one and consumed the key. The registered `handle`/`enabled` were
also captured per registration and read stale by the native keydown
listener.
Fix: register once per (handlerId, priority, actions); read `handle` and
`isActive` live from a ref; fold `enabled` into a fresh `isActive` so
the dispatcher re-checks it at dispatch time. Registration order stays
stable and callbacks stay current for all six consumers of the hook.
Add an e2e regression test that opens a live agent, opens New
Workspace, and presses Shift+Tab. It asserts the running agent's mode
is unchanged: its daemon-committed mode, plus no set_agent_mode_request
on the wire. It fails on the previous code (the mode was flipped to a
more permissive mode) and passes with this change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(app): restore workspace pin shortcut build
The shortcut change landed with an import path removed by the workspace tabs reshape, breaking app typecheck and web builds. Import the canonical workspace tab model.
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
* fix(omp): limit thinking levels to model's reported efforts
The omp provider exposed all six thinking levels (off/minimal/low/
medium/high/xhigh) whenever a model had reasoning enabled, ignoring the
per-model thinking config that omp reports via RPC. The OmpModelSchema
didn't parse the thinking field at all, so the model's efforts subset
and defaultLevel were discarded.
Parse model.thinking (efforts/defaultLevel/effortMap) in OmpModelSchema
and filter the thinking options in mapOmpModel to only the model's
reported efforts. The default is the reported defaultLevel when it's
in the filtered set, otherwise the first (lowest) effort. Older omp
versions that don't report thinking.efforts keep getting the full set.
Also fix createSession to not hardcode 'medium' as the thinking fallback
when the client doesn't send a thinkingOptionId — pass undefined so omp
uses its own model default instead of overriding it with a level that
may not even be in the model's effort set.
* test(omp): drop --thinking medium from provider-registry launch assertions
The thinking-level filtering PR changed createSession to pass undefined
instead of "medium" when no thinking option is selected, so buildOmpLaunch
no longer emits --thinking. agent.test.ts was updated but provider-registry.test.ts
still expected --thinking medium in two OMP launch assertions, failing server-tests
on ubuntu and windows.
---------
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
The base ref mismatch error printed the same value on both sides, so it
always read "expected master, got master". All four throw sites computed
`baseRef = compare.baseRef ?? resolvedBaseRef`, but the guard only fires
when the caller passed a ref, so `baseRef` and the "got" value were the
same string by construction. The value that actually differs -- the
stored `baseRefName` from the worktree metadata -- was never shown,
which left the error undiagnosable from the UI alone.
Build the message in one place and have the four guards throw it, naming
the refs `stored` and `requested`. The wording deliberately avoids
"expected": a caller's ref can be stale, but the stored ref can equally
be the wrong one, so the message states both facts and leaves the
diagnosis open.
The guards stay where they are. Only the message moves, so the condition
that fires each throw is still visible at the call site, and both refs
are narrowed to plain strings by the time the message is built.
Throw conditions are unchanged; only the message text differs.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* fix(server): keep the client port in X-Forwarded-Host
The service proxy stripped the port when setting X-Forwarded-Host, so a
workspace service that derives its public origin from forwarded headers
emitted absolute URLs pointing at port 80. Opening a service at
http://api--branch--repo.localhost:6767/ and letting it redirect gave
back a Location without the port, which the browser followed to nothing.
Host was already forwarded intact, so services reading Host were fine.
Nothing rewrites Location on the way back, so the wrong URL reached the
browser unchanged.
The strip was not localhost-only: buildPublicServiceProxyUrl preserves
an explicit port in publicBaseUrl, so a public alias on :8443 hit the
same path. It was a no-op only on the default-port public case.
Forward the authority verbatim instead, and add X-Forwarded-Port under a
never-invent, never-clobber rule: report only a port observed in the
Host header, and leave any value an upstream proxy already set alone.
Deriving it from the scheme would overwrite nginx's port on a
non-default listener, and the upgrade path's hardcoded "http" would turn
a correct 443 into 80. An empty inbound value carries no port, so it
does not count as a value to preserve; out-of-range ports are dropped
rather than passed on, since the authority is client-supplied.
The header block existed in four copies. Collapse them: the subsystem
now delegates to createScriptProxyMiddleware and
createScriptProxyUpgradeHandler (passing passthroughUnknown explicitly,
since the factory defaults it to true), and both remaining call sites
share one buildForwardedHeaders helper.
Adds the first tests to cover any x-forwarded-* header on this path,
driven over a real proxy with an upstream that echoes what it received.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(server): let the observed authority win over a forged port
X-Forwarded-Port deferred to an inbound value, so a client connecting
directly could send Host: svc.localhost:63735 with a forged
X-Forwarded-Port: 443 and have services build URLs for 443. Frameworks
apply X-Forwarded-Port after X-Forwarded-Host, so the forged value won.
This was inconsistent with the line above it: X-Forwarded-Host is
overwritten with the real authority unconditionally, and there is a test
asserting that. Both headers carry the same trust, so both now follow
the same rule.
First-hand observation wins; an inbound value is a fallback, never an
override. A port parsed from Host replaces any inbound X-Forwarded-Port.
When Host carries no port there is nothing to observe, so a proxy's
value survives untouched: the nginx-on-:8443 case, where $host drops
the port and X-Forwarded-Port is the only source.
The empty-inbound-value special case is gone; always overwriting when we
have an observation covers it with one branch less.
Reported by Greptile on #2288. Not a regression: before this branch an
inbound X-Forwarded-Port passed through untouched, so the forged value
already reached services.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* docs(server): correct the forwarded-port comment and note the limit
The comment claimed the parsed port was "the port the client really
connected on". It is not: parseHostHeaderPort reads the Host header,
which the client writes, and route lookup normalizes the port away
before matching, so any value reaches the proxy. The only observed
port would be req.socket.localPort, which this code does not use.
Replaced it with the actual reason the Host port wins, which is keeping
x-forwarded-host and x-forwarded-port from disagreeing: the former is
set from Host, and frameworks apply the latter afterwards, so a
mismatched inbound value would silently override the forwarded
authority.
Added a LIMITATION note recording that the forwarded authority is not
authenticated, that inbound x-forwarded-port is not checked against
trustedProxies, and that closing this needs that config threaded into
the subsystem and the upgrade path, which has no Express trust context.
Both predate this branch: Host has always carried a client-chosen port
and an inbound x-forwarded-port has always passed straight through.
Docs gain a matching section telling service authors to pin their public
origin in configuration rather than derive it from request headers.
No behavior change. Raised by Greptile on #2288.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Cmd+Shift+P did nothing whenever the active workspace's sidebar row was
not rendered. The `workspace.pin` handler was registered by the row
itself, gated on `selected && canPin`, so collapsing the row's section
unmounted the only handler for the action and the dispatcher found zero
candidates. The keypress was swallowed with no toast and no error.
Move the action to a single always-mounted handler keyed on the active
route selection, following the existing `useGlobalNewWorkspaceAction` /
`useActiveWorktreeNewAction` pattern, and delete the two per-row
registrations. Besides the reported case this also fixes a collapsed
status group, a collapsed Pinned section (so unpinning works too), and
focus mode.
The handler lives in a headless component rather than being called from
the root layout, so subscribing to the active workspace's pin state does
not re-render the whole app shell.
Two supporting changes:
- The controller now takes a narrow `PinnableWorkspace` instead of a
full `SidebarWorkspaceEntry`, so a caller without a sidebar row can
build one. Its in-flight guard moves to module scope, because the row
menus and the shortcut hold separate controller instances and a
per-instance guard would let a keypress and a menu click fire two
concurrent, opposite RPCs.
- The handler resolves the descriptor id via `useWorkspaceFields` rather
than reusing the route id. The route carries an opaque workspace id
that is not guaranteed to equal the descriptor id, which is why
`selectWorkspace` resolves it through
`resolveWorkspaceMapKeyByIdentity`. Both the RPC and the in-flight key
need the descriptor id so that rows and this handler agree on one
identity.
`workspace.archive` has the same row-scoped defect and is deliberately
left alone: it carries per-row state (`isArchiving`, optimistic hiding,
the risky-worktree confirm) and needs its own change.
Covered by a Playwright spec: the three collapse cases fail without this
fix, plus one-RPC-per-press and a rejected-pin case that asserts the
error toast and that the next press still succeeds.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
The Claude usage card showed only Session and Weekly. Anthropic moved
model-scoped weekly limits out of the top-level `seven_day_<model>` keys
into a `limits[]` array, and `seven_day_opus` / `seven_day_omelette` now
return null, so the scoped bar silently disappeared. Nothing errored, so
the card looked healthy while under-reporting.
Scoped limits are now read from `limits[]`, restoring a `Weekly · Fable`
bar. Session and all-models weekly keep coming from the top-level keys,
which is also where the Claude CLI reads them from.
A response mid-migration can describe one limit twice, as a legacy key
and as a `limits[]` entry, so both shapes normalize into a single
`ScopedLimit` and one predicate decides whether two are the same limit:
same dimension, same id when both carry one, else same normalized name.
That is the only comparison in the file. Comparing display labels would
conflate a surface with a model of the same name, and collapse ids that
differ only by punctuation.
The `limits[]` entry supplies identity, since that is the shape the API
is migrating towards. Its `percent` and `resets_at` are nullable, so
each field falls back to the legacy twin rather than discarding a value
the response did carry.
Legacy scoped windows adopt the same id scheme, so `weekly_opus` becomes
`weekly_model_opus`. Window ids are internal React keys, not
user-facing, and unifying them is what lets a limit keep one id
whichever shape of the response carried it.
Two supporting changes:
- `limits[]` entries are validated one at a time. The response goes
through a single parse, so one malformed entry would otherwise throw
and take the windows that already parsed with it.
- The provider stores the logger it was already handed and warns when a
response parses but yields no windows, when a scope resolves to no
name, and when an entry is unparseable. This failure was silent at
every level, which is why it went unnoticed. Warn rather than debug,
because file logging defaults to info.
Scoped windows render even at 0% and inactive, so a bar does not appear
and disappear between refreshes.
Closes#2302
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Searching the command center for a branch name like "master" returned a
Workspaces list where every row looked identical: the title was the
branch and the subtitle read "<hostname> · <branch>". On a single-host
machine the hostname repeats on every row, so there was no way to tell
which project each "master" workspace belonged to.
Add the project name to the workspace subtitle (host · project · branch)
and gate the host label behind multi-host, matching the Agents section.
The host is dropped on single-host setups, so the subtitle reads
"project · branch" and rows are distinguishable. Because searchText is
derived from the subtitle, typing a project name now matches its
workspaces too.
Extract the shared join into joinSubtitleParts() and route both the
workspace and agent subtitles through it, removing the duplicated
filter/join the Agents section hand-rolled.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* fix(app): preserve image attachment MIME types
Image intake discarded clipboard and desktop path metadata, then guessed JPEG when a later file object had no type. Resolve MIME once at the platform boundary and require it downstream.
* fix(app): honor native picker MIME metadata
* feat(cli): connect daemons to Hub through browser approval
Let interactive setup complete through a short-lived browser authorization while preserving --token for automation and existing integrations.
* ci: build server before CLI tests
* fix(cli): keep Hub approval polling within expiry
Bound both response headers and body consumption to the original authorization lifetime so stalled responses remain retryable without extending approval. Run the CLI test helper through Node and the resolved tsx entry for cross-platform behavior.
* fix(cli): bound Hub registration startup
Fail a stalled authorization start within a fixed timeout without retrying the non-idempotent request. Declare the response validator as a direct CLI runtime dependency.
* fix(cli): restrict Hub activation URLs
* fix(cli): harden Hub approval transport
* fix(pairing): keep terminal QR codes scannable
Prompt framing could wrap dense QR codes and alter long pairing links. Print pairing instructions directly, add the standard quiet zone, and suppress codes that cannot fit without auto-wrapping.
* fix(pairing): contain terminal QR background
Render optimistic turn feedback before host acknowledgement and roll it back on rejection. Reconcile iOS keyboard offsets from the native transition end event so JS contention cannot leave the composer displaced.
Route live OMP custom messages through the existing system-notice mapper so background notices render as task notifications while advisor, hidden, and ordinary custom-message behavior stays intact.
Ordinary queries were retained for the renderer lifetime, allowing large file previews to accumulate. Explicit replica and local-state queries keep their own lifetime policies.
Workspace update fanout was constructing one-off reconciliation services, so bursts could launch overlapping all-workspace scans. Keep reconciliation in the daemon service and preserve runtime cleanup for missing workspaces.
* feat(server): expose daemon Git pressure metrics
Separate limiter queue wait from Git execution time and report subscription ownership so accumulating work is visible in the existing runtime log.
* fix(server): collect subscriptions in agent metrics
Reuse the existing agent snapshot during runtime flushes so WebSocket shutdown does not require an additional AgentManager method call.
* test(server): retry transient hub cleanup
Retained group headings could enter loading after their initial layout, leaving React Native Web without a registered layout observer and the shimmer with a zero-length endpoint. Measure web badge labels from mount so later loading states reuse real dimensions.
* fix(acp): prevent duplicate image prompts
Some ACP agents echo submitted prompts without preserving client message IDs. Attribute live user chunks to the active submitted turn, while coalescing provider-owned chunks outside it.
* fix(acp): flush user messages on failed turns
* fix(acp): flush user messages on session close
* fix(app): preserve file line endings and UTF-8 BOM
* refactor(app): simplify line ending preservation
Let the editor parse newline variants and serialize with the file's first separator. Mixed-ending files become uniform on edit without a separate normalization layer.
---------
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
* fix(workspaces): stop archived workspace subscriptions
Workspace mutations were persisted globally but projected only through the initiating session, leaving other sessions' Git observers alive after archive. Publish mutations from the shared registry so every session releases its local observer and receives exactly one update.
* fix(workspaces): serialize workspace mutation observers
* fix(workspaces): finish mutation cleanup
Queued workspace mutations now stop at the session cleanup boundary and remove any observer created across an asynchronous cleanup race. Project removal can still enrich an earlier removal delta for legacy clients.
* fix(workspaces): honor global mutation boundaries
Serialize project and workspace lifecycle notifications per session, keep filtered subscriptions from owning unrelated Git observers, and broadcast final project removals to every session that saw the project. Update strict Session harnesses for the registry subscriptions.
* fix(workspaces): preserve initial agent status
Carry initial-agent intent through global workspace mutations and publish the optimistic running status until the agent contribution takes over. This removes the invalid transient Done state exposed by global subscriptions.
* fix(app): refine compact composer controls and native scrolling
Consolidate responsive agent controls and model browsing inside the compact sheet. Preserve manual native scrolling by suspending sticky bottom maintenance while the user owns the viewport.
* fix(app): suppress recoverable resume refresh errors
Resume revalidation can race host reconnection and reject while the host is offline. Treat the refresh as deferred so development LogBox does not surface a recoverable disconnect.
* fix(app): stabilize compact model sheet loading
Let the model list consume the sheet space above persistent controls as the sheet expands. Keep unresolved defaults in a loading state so Select model only represents a genuinely empty selection.
* fix(app): preserve route anchors during native scroll
* fix(app): preserve native scroll intent and sheet dismissal
* fix(app): keep compact sheet styles composable
* feat(commits): distinguish workspace history from base commits
Keep every workspace commit visible while bounding base history to ten
context commits, and make push and base state readable in the commit rail.
* fix(commits): preserve history when base refs disappear
Fall back to recent HEAD history for stale saved bases, and reject truncated
git output instead of presenting an incomplete commit list.
* fix(commits): classify history against the current base
Use the furthest-ahead local or remote base for classification, and re-infer
base history when saved worktree metadata points to a deleted branch.
OmpRpcSlashCommandSchema had a strict source enum
['extension', 'prompt', 'skill', 'builtin'] that rejected omp
commands with source 'custom' or 'file'. When omp reported any such
command, the entire get_available_commands response failed Zod
validation and listCommands returned an empty array — so the Paseo
autocomplete only showed /exit and /clear instead of all omp commands.
Relax source to z.string().optional(), matching the already-permissive
OmpAvailableCommandSchema used for the available_commands_update event.
* fix(app): open notifications on the right host
Agent notifications previously omitted workspace ownership, so a cold target host was treated as missing and fell back to its empty home route. Carry the authoritative workspace and keep older notifications on a target-host resolver until lookup is conclusive.
* fix(app): separate notification and agent URL routing
Notifications use their authoritative workspace target directly. Stable agent URLs remain server-and-agent targets whose workspace is resolved by the agent route.
* fix(protocol): preserve notification workspace targets
Both attention message variants retain workspaceId through outbound validation so notification clicks receive the authoritative route target.
* test(app): use workspace-scoped notification target
* test(server): give notification agents workspace targets
* test(app): target notification workspace in navigation e2e
A usage bar in Settings -> Usage stayed green all the way to 100%, so
the one moment the meter matters was the moment it said nothing.
The client already computes this. `window-bar.tsx` reads
`window.tone ?? deriveTone(usedPct)`, and `deriveTone` escalates past
70% and 90%. But `usage.ts` only assigns `window.tone` when a provider
sends one, so any provider that sends a tone opts out of the thresholds
entirely. Claude and Kimi hardcoded `tone: "ok"`, and Codex escalated to
`warning` past 70% but could never reach `danger`.
Providers now derive tone from the percentage they already have, through
one shared `toneFromUsedPct` whose thresholds match the client's.
Claude, Codex and Kimi windows all escalate correctly.
Balances had the same gap by a different route: `balance-bar.tsx` has no
`deriveTone` fallback, and `balanceToneFromRemaining` only escalates
once a balance is completely spent, so a credits bar at 99% was green.
Cursor and Grok know their limit, so they now tone by used percentage.
Codex credits report only a remaining balance with no limit, so they
keep the remaining-based tone, documented as the no-limit case.
MiniMax is unchanged: it maps a status its own API supplies rather than
hardcoding a tone.
Verified against the live Anthropic API with a session window at 83%,
which now reports `warning` where it previously reported `ok`.
Closes#2320
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* Accept OMP session state without thinkingLevel
Models whose reasoning effort is encoded in the model ID (e.g.
cursor-grok-4.5-high-fast) are marked reasoning: false by OMP, which
omits thinkingLevel from get_state. The required schema field made
session creation throw a ZodError. Make it optional and resolve the
thinking option to null when absent.
* Render OMP advisor messages as blocks
Open the complete working comparison in a desktop workspace tab while preserving inline sidebar diffs when the tab is closed.
Working and commit comparisons share one diff panel, and workspace layout is the sole owner of live tab persistence and identity.
Refs #1520
* feat(desktop): open existing agents from links
Register a stable agent deep link and route it through the existing Desktop window. Add a matching CLI command that resolves the local server and activates the requested agent without creating or messaging it.
* fix(desktop): recover agent link delivery
* Fix pending checkout diff subscriptions
* Reduce checkout status Git spawns
* fix(checkout): own pending diff cancellation
Checkout sessions could invalidate a subscription before its shared watch had finished opening. Register targets synchronously and let the diff manager honor cancellation so pending and concurrent subscriptions share one teardown path.
---------
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
* fix(app): restore archived agents from History
History navigation lost the explicit archived-agent intent during tab reconciliation, and workspace recovery stopped after restoring the workspace. Preserve the selected tab and recover its provider session as one action while serializing provider resume with timeline hydration.
* fix(app): preserve archived agent recovery invariants
* fix(server): preserve timeline hydration call shape
* fix(app): retain agent tabs through lookup
* fix(server): upgrade in-flight timeline broadcast
The outer batch shell expanded the hook variable and control operators before terminal send-keys received them. Encode the probe for PowerShell so expansion happens inside the packaged terminal.
Keep layout-only styles on stable React Native style IDs when they share Reanimated hosts with keyboard transforms. This prevents style regeneration from disturbing the animated native views while preserving keyboard and safe-area behavior.
Custom line-break rules dropped the resolved full-width break style, allowing adjacent native text spans to collapse onto one line. Preserve the break styles in both chat and shared Markdown renderers.
Large project, status, and pinned groups now render 20 rows until expanded. Newly discovered workspaces are prepended without disturbing the saved order.
* feat(app): show recent commit history in explorer
* fix(app): keep recent commit history accurate
* fix(app): include merged commits in recent history
* perf(app): bound recent commit classification
* fix(app): refresh recent commits on Git updates
* fix(app): open chat file links at referenced lines
Pass chat file locations through the editable CodeMirror path so opening a reference selects and scrolls to its requested line. Cover the real assistant-link flow with a browser regression.
* fix(app): repeat chat file navigation
Track file navigation separately from stable tab identity so reopening the same path and line recenters both editable and read-only panes. Extend the browser regression to cover moving away and clicking the same link again.
* test(app): stabilize repeated file link navigation
The inverted list's native position maintenance fought the sticky-bottom controller as the live message grew. Let the controller exclusively own the Android sticky-bottom anchor while preserving position maintenance when reading history.
Timeline catch-up treated the first unmatched optimistic prompt as an insertion boundary, which could move later acknowledged prompts behind assistant output. Apply canonical entries directly and preserve submission slots during replacement.
Live canonical user rows consumed the oldest optimistic prompt even when a client message ID identified a later submission. Match identified rows exactly while retaining FIFO reconciliation for legacy rows.
The desktop daemon resolved the CLI through a packaged module entrypoint that is not an executable outside the archive. Publish the existing bundled shim as the daemon's authoritative CLI path so terminal hooks use a callable command.
The web UI font rule only targeted the app root, so portaled content fell back to the default font. Extend the rule to the shared overlay root and cover it with a browser regression test.
Visibility catch-up could select the blocking overlay after optimistic continuity cleared, even though history was already hydrated. Keep hydrated content non-blocking and cover the creation handoff with a browser regression test.
* fix(workspaces): start pasted pull requests from their branch
Recognized pull request links now select their branch directly instead of
waiting for a separate confirmation. Ensure single-branch clones also record
the fetched branch so the new worktree can track it.
* fix(workspaces): keep pasted PR checkout target-safe
* fix(workspaces): make pasted PR resolution deterministic
* fix(workspaces): preserve explicit checkout selection
A recognized pull request selects itself only when its attachment is added. Once the user chooses a branch, later composer edits must not derive checkout state from existing attachments.
* fix(composer): ignore stale pull request lookups
A lookup may finish after the workspace target changes. Only apply its attachment and selection event when the host, working directory, client, and remote still match the target that started it.
* fix(workspaces): make checkout choices authoritative
Arm automatic pull request selection only when a new PR link is detected. An explicit picker choice cancels that pending selection, existing draft attachments never infer checkout state, and target changes suppress carried-over lookups.
* fix(workspaces): stabilize pull request auto-selection
Keep the first PR selected when one edit contains several links, and accept a completed lookup when only the transport client changed for the same host and checkout.
* fix(composer): release removed pull request lookups
Release each auto-attach invocation when its composer state is abandoned, while continuing to discard any stale response.
Expected rev-parse failures were logged like unexpected discovery errors after Git detection became fail-open. Keep ordinary non-Git results silent while retaining diagnostics for genuine process failures.
Make the host runtime own session and setup replicas for the registered host lifetime. Provider remounts can then reattach without clearing directory snapshots or timeline cursors.
Keep service route choices independent of daemon transport and persist the selected route per host. Use the shared web overlay stack so menus, tooltips, and toasts layer predictably.
Track proxy route changes from the Git branch rather than the workspace title to avoid transient service health changes.
* fix: emit project.update on rename so empty projects update
The rename handler only re-emitted workspace descriptors, which left
empty projects (no workspaces yet) showing the stale name on the client.
* test(projects): cover renaming empty projects
---------
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
* feat(pi): add 'max' thinking level support
Pi recently introduced a new 'max' thinking level beyond 'xhigh'.
This adds it to the type union, the UI options array, and the
runtime type guard so Paseo users can select it.
Changes:
- rpc-types.ts: add 'max' to PiThinkingLevel union
- agent.ts: add max entry to PI_THINKING_OPTIONS
- agent.ts: add 'max' to isPiThinkingLevel guard
* fix(pi): clarify xhigh description after adding max level
xhigh's 'Maximum reasoning' is now misleading since max is the true
maximum. Changed to 'Very deep reasoning' per review feedback.
* feat(providers): allow turning thinking off
Expose Off only for models that support disabled thinking while preserving Low as the default.
* fix(providers): reset unsupported thinking on model change
Keep the selected thinking option only when the new model advertises it; otherwise use that model's default and persist the runtime change.
* fix(providers): isolate custom model capabilities
Use strict manifest identity when reconciling thinking options so provider-prefixed custom models cannot inherit capabilities they do not advertise.
* fix(providers): clear thinking with default model
* fix(providers): validate disabled thinking support
* fix(providers): validate initial thinking config
* refactor(providers): centralize thinking capability
* fix(server): order config mutation events
The local daemon's serverId persists while the daemon is stopped, so it isn't
among the connected hosts. The settings host-section resolver fell back to it
without checking it was connected, resolving the section to an unknown id and
rendering "host not found".
Extract the fallback into resolveActiveHostServerId, which only uses a serverId
when it names a currently connected host (covering both the picker selection and
the local daemon), and add regression tests.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(files): edit workspace files on web
Keep source buffers synchronized with host file changes and require an explicit overwrite or reload when revisions diverge.
* feat(panels): surface and protect modified tabs
Expose tooltip and modification state through the generic panel boundary so tabs can show stable metadata and guard every close route consistently.
* fix(tests): use portable fake timeout handle
* fix(files): harden editor conflict handling
Preserve modified panel state across tab eviction, use precise revisions for optimistic writes, coalesce concurrent file watchers, and localize the editor interface.
* fix(files): close editor concurrency gaps
Coalesce clean reloads, preserve subscriber identities and file permissions, suspend pending saves during close confirmation, and carry precise revisions through file reads.
* test(files): expect read revision metadata
* starr
* fix(server): honor service port allocator contracts
* fix(server): pass workspace context to port scripts
* fix(server): cancel released service port plans
* test(server): support Windows port allocator fixtures
* docs(worktrees): clarify shell-less portScript execution
The composer mirrored dictation state in a second ref that could remain stale after the recording surface changed. Route shortcut decisions through the dictation lifecycle's synchronous state so finishing or rejecting a recording cannot leave Command-D latched.
* fix(opencode): surface autonomous parent turns
OpenCode extensions can prompt an idle session without Paseo owning an active turn. Adopt exact-session activity while keeping child permissions unbound.
* refactor(opencode): centralize session ownership guard
Keep autonomous turn ownership in the shared exact-session check so all qualifying events use one source of truth.
* fix(opencode): serialize autonomous handoff
Abort and await provider-side autonomous work before starting a direct Paseo prompt so late output cannot leak into the new turn.
* fix(opencode): reject stale interrupted activity
Keep late output from an interrupted Paseo turn out of autonomous admission until a real user message establishes the next run.
* fix(opencode): fence interrupted turn events
Use provider abort settlement and terminal events as the interrupted-turn boundary so delayed canceled user messages cannot resurrect old work.
* fix(opencode): recover from failed aborts
* fix(opencode): preserve timed-out abort fence
* refactor(opencode): model provider turn lifecycle
* fix(opencode): recover stop fence after stream loss
Canonical provider message IDs and optimistic client IDs occupy different namespaces. Preserve both identities for deterministic reconciliation while retaining a dated content fallback for older daemon timelines.
Cached focused state was leaking into the sidebar before authoritative workspace hydration, while catch-up limits counted raw events instead of projected entries. Keep the focused cache fast, gate directory presentation on hydration, and preserve canonical cursors while paging projected history.
* fix(nix): package local speech worker
* fix(nix): reuse sherpa package helper
* fix(nix): patch sherpa-onnx prebuilt binaries for NixOS libstdc++
The prebuilt sherpa-onnx-linux-x64 .so files link against libstdc++.so.6
which is not on the NixOS library path. Add autoPatchelfHook to fix ELF
RPATHs and stdenv.cc.cc.lib to provide the C++ runtime, resolving the
"Failed to load model because protobuf parsing failed" SIGABRT at
speech worker startup.
* fix(app): keep sidebar pins visible while reopening
Hidden sidebars stop consuming live workspace updates but retain their last rendered entries so the opening animation never exposes an empty list.
* test(app): reuse sidebar pin flow
* fix: resume collected agents before pane actions
* fix(server): keep archived agents closed during pane actions
An archive could win while a collected agent was resuming, then lose when the provider runtime registered. Recheck persisted archive state after registration and close the resumed runtime before any mutation runs.
* fix(server): fence archived agents after shared resume
Protected pane actions could join a resume started by an ordinary loader and skip the archive fence. Recheck persisted lifecycle state for every protected caller so archive always wins before mutation.
* fix(server): release resources held by idle agents
Keep unarchived agents resumable while closing their provider runtimes after two minutes. Active agent schedules keep runtimes resident.
* fix(server): preserve resumable agent state
* fix(server): resume agents when listing commands
* fix(server): preserve collected agent interactions
Keep a small non-authoritative view of the last focused chat so the app can paint it before the daemon finishes revalidation. Bound persistence to one focused agent per host and evict whole host entries to limit storage and serialization work.
Module-level style composites could materialize the temporary adaptive theme before persisted settings loaded. Keep Unistyles reads in render and guard against eager module-scope access.
* Switch models from the Command Center
Add a model switcher to the Command-K Command Center. Typing surfaces a
flat, filterable list of "Model › Provider › Name" breadcrumb rows with
provider icons:
- Running agent: its own provider's models (a live agent can't change
provider); selecting calls setAgentModel.
- New draft tab: every available provider's models in one flat list;
selecting sets provider + model on the draft via a focused-draft
controller published to a global store (the draft form state is local
to the composer subtree and otherwise unreachable from the global
Command Center).
Models only appear once the user starts typing, so the default palette
view is unchanged. Reuses useProvidersSnapshot and the existing
setAgentModel RPC — no protocol changes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Update packages/app/src/components/command-center.tsx
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
* Update packages/app/src/hooks/use-command-center.ts
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
* refactor(app): make Command Center extensible
Let focused features register stable actions while the palette owns search, selection, and a single virtualized result projection.
* fix(app): save model preference after agent switch
Persist the shared model choice only after the daemon confirms the live agent switched successfully.
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
* fix(app): keep focused agent timelines live
Window focus was conflated with app visibility, so switching OS windows could remove the selected timeline subscription. Separate those signals, grace every visibility-driven removal, and cover retained history until authoritative catch-up completes.
* fix(app): keep timeline catch-up failures non-blocking
* fix(app): surface timeline catch-up failures
* fix(app): preserve file preview refocus
* fix(app): preserve optimistic catch-up flow
Pi compact is a blocking LLM summarization job that often exceeds the
default 30s control-plane timeout on long sessions, causing a false UI
error while the real compact continues. Wait for the RPC response,
process exit, or session close instead.
Also cover the no-timeout lifecycle: null timeout still rejects on
close, and compact waits past 30s for a late success response.
Closes#1946
The voice overlay transition could retain stale native opacity after the app was backgrounded, leaving the mounted composer transparent. Derive both surfaces directly from voice state so they cannot disagree after resume.
* fix(projects): allow non-git folders across mounts
Treat failed Git worktree discovery as non-Git so ordinary directories remain addable. Preserve the underlying diagnostic as a structured warning instead of failing project creation.
* fix(projects): preserve discovery warnings
Propagate checkout context through the remaining worktree discovery calls so fail-open classification retains its structured warning.
* fix(projects): detect when projects become Git repositories
Project identity was coupled to Git placement, while non-Git roots were dropped by session-scoped observation. Keep identity tied to the selected root and observe Git transitions daemon-wide so empty projects update without rehoming workspaces.
* fix(projects): preserve metadata across Git read failures
Refresh archived workspace facts before persistence and treat only confirmed non-repositories as non-Git so transient failures cannot rewrite stored project metadata.
* fix(projects): close project update races
* fix(projects): refresh checkout metadata when reopening folders
Persist worktree ownership separately from workspace kind and gate exact-root project creation on stable host identity. Refresh active and archived records so missed Git transitions cannot return stale checkout descriptors.
* test(projects): accept Windows aliases for repaired roots
* test(server): retry transient Windows cleanup locks
* fix(projects): propagate project metadata refreshes
Project-only Git transitions now fan out workspace updates for legacy clients. Explicit project creation refreshes stored kind, and equivalent cwd spellings reuse existing workspace records.
* fix(projects): refresh worktree source project kind
* fix(projects): preserve exact-folder runtime isolation
* fix(projects): preserve exact cwd across worktree lifecycle
* fix(projects): harden exact-folder worktree flows
* fix(projects): derive exact cwd from matched path identity
* fix(projects): preserve nested worktree lifecycle
* refactor(projects): centralize workspace placement
* test(e2e): verify isolated server ports
* fix(projects): preserve placement reshape compatibility
* fix(projects): validate created worktree placement
* fix(projects): preserve placement through workspace lifecycle
Archive and recovery were rediscovering or guessing checkout roots instead of consuming the persisted workspace placement. Make the record authoritative for exact cwd, backing worktree, and source repository, and classify stale paths before Git reconciliation.
* fix(worktrees): preserve source checkout root
Convert Git's common administrative directory back to the source checkout root when legacy archive placement is reconstructed.
* fix(worktrees): compare filesystem identities
Resolve discovered worktrees and teardown locations through the shared realpath-aware matcher so Windows short and long path spellings cannot split placement identity.
* fix(worktrees): centralize path containment
Route worktree ownership, listing, resolution, and deletion through the realpath-aware containment primitive so Windows path aliases cannot be filtered by an earlier raw prefix check.
* fix(worktrees): remove duplicate ownership discovery
* test(e2e): isolate daemon restart ownership
* fix(worktrees): make lifecycle operations transactional
* fix(workspaces): share git watches by cwd
* test(worktrees): make teardown proof portable
* fix(sync): integrate project updates with directory owner
* fix(workspaces): close reconciliation edge cases
* refactor(sync): remove duplicate project reconciliation
Keep workspace and project deltas behind the host directory transaction, with owner-boundary coverage for snapshot replay and queued full reconciliation.
* fix(sync): buffer project updates before hydration
Start the workspace transaction with the online connection epoch so project broadcasts cannot publish a partial directory before the authoritative snapshot commits.
* feat(sync): keep live data scoped and current
Only viewed chats receive live timeline rows, while directory state now uses one subscribed bootstrap followed by ordered deltas. Legacy clients and daemons retain their existing behavior through centralized compatibility gates.
* fix(sync): preserve selective delivery boundaries
Keep selective timeline capability app-owned, union viewed sets across shared sockets, and reconcile directory side effects from accepted state. Visibility and archive suppression now follow their existing authoritative boundaries.
* test(app): align browser fixtures with runtime contracts
* fix(sync): preserve mixed-client delivery guarantees
* fix(sync): finish paged history before advancing
* fix(sync): replay live deltas after refresh failures
* test(server): model socket capability lookup
* test(app): use platform shortcut for split-pane coverage
* fix(app): keep hidden timelines dormant
* test(app): normalize split-pane shortcut setup
* fix(app): preserve sync state across retries
* fix(app): preserve background sync state across races
* fix directory bootstrap reconciliation edge cases
* fix selective timeline compatibility races
* fix superseded directory sync races
* fix(sync): centralize directory replica ordering
Own agent and workspace refresh transactions in HostRuntime so reconnect epochs, buffered updates, and lifecycle invalidation share one ordering boundary. Route timeline metadata and mixed-capability stream delivery through their authoritative runtime sources.
* fix(app): keep timeline requests runtime-scoped
* fix(app): close directory bootstrap races
* fix(desktop): keep browser tabs connected across workspaces
Electron can replace a guest WebContents when a retained browser tab is reparented. Re-register each attachment and keep background actionability checks running so agent browser tools retain the tab through workspace eviction.\n\nCover the full app, daemon, Electron, and MCP path in the existing desktop CI job.
* fix(desktop): launch Electron E2E reliably on Linux
CI Electron must receive --no-sandbox before the app starts because the hosted runner cannot use Electron's bundled SUID sandbox helper. Forward explicit dev-runner arguments and fail readiness waits as soon as a child exits.
* fix(desktop): preserve active browser on repeated registration
* fix(desktop): keep browser keyboard attachment idempotent
* fix(desktop): wait for E2E bridge readiness
* fix(desktop): close E2E logs after output drains
Mouse input inherited the touch hold delay. Split activation by input so mouse dragging begins after deliberate movement while touch retains long-press arbitration.
* fix(terminal): retry size claims until sent
A focus resize could consume its once-per-focus latch before the renderer handed the size to the client. Keep the claim pending across visibility, connection, and renderer readiness changes, and only commit it after the resize is sent.
* fix(terminal): keep disconnected claims retryable
A retained runtime client can outlive its active connection. Require the connection at delivery time so a delayed resize callback cannot commit a claim that was never sent.
* test(terminal): await PTY resize observation
Prove the terminal starts at 80x24 while blurred, then probe through the daemon until its own reported size matches xterm after focus returns. This avoids racing a one-shot stty sample against the asynchronous resize claim.
* Reapply "Always revalidate desktop updates before install"
This reverts commit 623c05aa4d.
* fix(desktop): make update revalidation safe on quit
Updater-triggered quits now bypass normal quit handling, and manifest revalidation gives up after five seconds without allowing a late install.
* fix(desktop): preserve fail-closed update installs
Keep cached updates deferred when quit-time validation is offline, and report validation timeouts separately from superseded releases.
* fix(desktop): preserve updater quit behavior
Automatic installs remain silent without relaunching, updater handoff is bounded, and background preparation failures remain visible without pinning manifest checks.
* fix(desktop): keep AppImage updates manual
Preserve the AppImage safety exemption by skipping ordinary quit-time installation while retaining the explicit Update now path.
* fix(desktop): serialize update preparation
* fix(desktop): recognize macOS updater quit handoff
* refactor(forge): forge-neutral foundation (GitHub-only)
Decouple git-hosting from GitHub behind a neutral abstraction (issue #1616), GitHub-only for now; existing GitHub behaviour is unchanged.
- Forge manifest, neutral ForgeService contract, forge registry + resolver, and a client forge-module registry.
- GitHub code renamed to the neutral shape; PR/Issue attachment wording preserved.
- forge.search.response enums parse tolerantly (unknown kind/auth state degrade instead of breaking the client).
- createPullRequest reports typed CLI/auth errors instead of a generic message.
- forge-resolver host/remote caches are LRU-bounded.
- Forge host trust is explicit: only a known cloud host or a CLI-authenticated host is ever talked to; an unauthenticated GitHub Enterprise host fails resolution instead of routing to github.com.
- Docs: forge-providers guide, glossary and i18n forge-copy conventions, architecture and rpc-namespacing terminology.
- Vitest React Native mocks (unistyles, svg, linking, lucide) consolidated into shared aliased test-stubs.
* feat(forge): GitLab adapter, forge-aware UI, pipelines and approvals
GitLab adapter over the glab CLI on the neutral contracts: MR status, forge-aware UI, pipeline tree, and N-of-M approvals.
- threadIsResolved is part of the neutral timeline item.
- Pipeline load failures show an error instead of an empty section.
- Manual pipeline jobs render as pending.
- Fork/detached MR head pipelines are fetched by MR iid (glab ci get --merge-request).
* feat(forge): Gitea family adapter (Gitea, Forgejo, Codeberg)
One adapter over the tea CLI serving Gitea, Forgejo, and Codeberg on the neutral contracts.
- CI status aggregates commit statuses and Actions runs together.
- Gitea's terminal "warning" state maps to failure on server and client.
- Gitea Actions check details are reachable from the PR pane by workflowRunId.
* refactor(forge): localize compatibility handling
* test(forge): expect normalized GitLab facts
---------
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
* fix(desktop): keep browser input out of the composer
Unhandled webview keys could be redispatched into the active host window, allowing agent Enter to submit a draft prompt. Give pages first refusal for ordinary shortcuts and contain automation at the guest boundary.
* fix(desktop): tighten browser shortcut validation
* fix(desktop): respect browser shortcut ownership
* fix(desktop): retain browser keys across windows
* fix(desktop): scope browser webviews by host
* Reshape browser shortcut ownership
Use the browser webview registry as the single guest identity authority, scope browser operations to their host window, publish chord continuations only while pending, and restore focus to the originating browser after command center dismissal.
* Fix host-scoped browser shortcut follow-ups
* Fix browser keyboard review follow-ups
* Fix browser keyboard lifecycle regressions
* Fix browser shortcut review regressions
* Fix desktop browser review regressions
* Fix browser shortcut frame regressions
* Fix rebase integration regressions
* Preserve browser-native shortcut ownership
* Isolate browser shortcuts and automation by context
* fix(workspace): keep focus mode scoped and easy to exit
Route focus mode through the active workspace so persisted state cannot hide chrome on settings or other screens. Add a visible exit control and keep desktop window chrome aligned and visually quiet.
* fix(app): clarify muted chrome semantics
Pi and OMP share only the JSONL child-process transport while retaining provider-owned launch, RPC, runtime, session, history, and permission behavior. Removes captured fixtures in favor of typed harnesses and real-provider coverage.
Closes#2006Closes#2060
ACP setup and out-of-prompt notifications do not define a turn lifecycle. Treating them as autonomous turns could complete the active run while its prompt was still streaming.
* fix(sessions): reimport archived sessions in their workspace
Archived agent records were treated as active imports, hiding their provider sessions permanently. Workspace-originated imports also discarded their workspace identity and created a duplicate workspace.
* fix(sessions): validate archived session restores
Restore archived imports under their existing Paseo agent identity, reject stale workspace ownership, and gate workspace targeting when the host cannot honor it.
* fix(sessions): roll back failed archived imports
If provider resume or history hydration fails, close any partial runtime, re-archive the provider session, and restore the original stored agent record.
* fix(sessions): validate restored import ownership
* fix(sessions): serialize concurrent restores
* fix(sessions): harden archived import recovery
* fix(sessions): validate archived import placement
* refactor(sessions): centralize provider session imports
Keep workspace placement rollback and stored-agent activation behind their existing owners so Session remains the wire boundary.
* fix(desktop): keep sign-in popups connected
Turning every window-open request into a workspace tab severed the opener relationship required by popup authentication flows. Keep script-created and POST-backed opens as secured child windows while ordinary links continue to use workspace tabs.
* fix(desktop): keep Shift-click links in workspace tabs
Electron reports both popup windows and Shift-clicked links as new-window. Use popup features or a named target to preserve script popups without bypassing Paseo tab state for ordinary links.
* fix(desktop): tighten popup profile handling
* fix(desktop): preserve popup feature intent
* fix(desktop): match browser popup heuristics
* feat(app): add fileView preference for changes panel
* feat(app): add buildDiffTree util for changed-files tree
* feat(app): add changed-files tree directory row
* feat(app): changed-files tree view mode in changes panel (#117)
* feat(protocol): add checkout.commits.list RPC + commitsList feature flag
* feat(server): list branch commits ahead of base with on-remote flags
* feat(server): handle checkout.commits.list RPC and advertise capability
* feat(client): checkout.commits.list method + useCommitsQuery hook
* feat(app): per-commit inline view with local-vs-remote markers (#117)
* feat(protocol,server): per-commit file diff RPC (checkout.commits.file_diff)
* feat(app): open per-commit file diff on click (#117)
* refactor: surface baseRef + commits loading/error, drop dead depth field
* fix(app): flip commit local/remote dot — local hollow, remote filled
* feat(app): list/tree view for expanded commit file list (#117)
* fix(app): syntax-highlight per-commit file diff to match Changes view
* feat(app): draggable resize between commits and diff sections
* refactor(app): dedupe wrap-text helpers into diff-highlighted-text
* fix(app): clean up commits resize drag on unmount + a11y label
* fix(app): collapse commits section by default
* fix(app): render per-commit file diff with the shared Changes line renderer
* perf(app): memoize shared diff line row; drop redundant DiffLineView wrapper
* refactor(app): extract shared DiffFileBody; render commit file diff through it
* fix(app): hide inline-comment affordance in per-commit diffs (no reviewActions)
* feat(app): move commits to a resizable bottom drawer in the Changes panel
* feat(review): commitSha scoping for per-commit review drafts + attachment
* feat(app): per-commit inline comments wired through to the composer (#117)
* refactor(app): own commit file-diff open state in CommitFileList (drop reset effect)
* refactor(app): colocate diff-render cluster under git/diff-file-body/
* feat(app): add diff tab target kind (working/commit diff tabs)
* feat(app): useDiffFiles hook unifying working + commit diff targets
* feat(app): diff tab panel rendering working/commit diffs
* feat(app): open a commit diff tab on commit click; drop per-commit drawer/file list
* feat(app): open/scroll working diff tab on changed-file click
* fix(app): keep commit diff tabs ephemeral; align working diff whitespace
* feat(app): collapsible file sections in the diff tab, collapsed by default
* feat(app): make the on-remote commit dot more subtle
Dims the filled green remote dot (row + legend) to ~0.55 opacity so the
local-only ring stays the state that draws the eye.
* Reshape commit diffs around the existing Changes view
* Fix diff tab migration complexity after rebase
* Address diff tab review findings
* Clean up diff tab review interfaces
* Collapse commit diffs into the existing view
* Load commits when expanded
---------
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
* fix(app): restore missing workspaces from History
Workspace and agent archival are separate lifecycles. Carry restore intent from History so closed agents can reopen archived workspaces without changing ordinary navigation.
* fix(app): wait for workspace hydration before restore
* fix(app): preserve History agent unarchive
* fix(app): reopen archived History agents outside active directory
History entries are not merged into the active session maps. Carry the row archive state through explicit restore intent so live workspaces still reopen without broadening ordinary navigation.
* fix(app): ignore stale History archive state for active agents
A cached History row can outlive a successful reopen. When the workspace exists, live session state now wins so an active agent is not interrupted by another refresh.
* fix(app): reopen every selected archived History agent
History entries without workspace IDs returned before restoration, and a second archived agent sharing an in-flight workspace restore was dropped. Preserve both agent-only and deferred reopen intent.
* fix(app): require explicit workspace recovery
* fix: preserve workspace recovery contracts
* fix: preserve workspace recovery compatibility
* fix: preserve recovered workspace session state
* fix: preserve terminal navigation timing
* fix(server): preserve successful workspace recovery
* fix: preserve workspace recovery intent
* fix(client): fail invalid RPC responses immediately
Correlated responses that failed schema validation were discarded before request matching, leaving callers blocked until the RPC timeout. Preserve only the raw correlation identity at the boundary so the matching request fails with a protocol error.
* test(client): preserve invalid response diagnostics
* fix(client): ignore invalid correlated progress events
* feat(providers): remove custom providers from settings
Add a destructive removal flow so mistaken custom providers can be deleted from config.json instead of only disabled.
* test(app): cover provider removal with e2e
Move provider removal coverage out of mocked component tests and into the real Settings flow.
* fix(providers): keep removal live after config updates
* fix(settings): show daemon update failures
React Native web ignores Alert.alert, so failed remote updates reset to an enabled button without any user-visible explanation. Keep updater progress and failure as explicit rendered state, including the daemon's error, and cover the real browser-to-daemon failure path.
* test(settings): harden daemon update regression
* fix(settings): disable Desktop-managed daemon updates
Desktop-bundled daemons advertised npm self-update even though the install-origin checks always rejected it. Report Desktop ownership to clients, render actionable guidance, and refuse stale requests before touching npm.
* feat(desktop): open files in more installed editors
Detect bundled app commands as well as PATH launchers and preserve file positions through editor-specific launches.
* fix(desktop): preserve editor target compatibility
Keep existing file-manager preference ids, retain project context for file launches, and recognize Windows 64-bit IDE commands.
* fix: hide browser pin on non-Electron web
Browser is desktop-only, but the pinned shortcut bar still rendered a
New browser pin on web where createBrowser is a silent no-op.
Skip browser pins in usePinnedLaunchers when not Electron, and drop
browser from default pinned targets so new users only get terminal.
* fix(app): preserve browser pin defaults on desktop
Keep platform availability in the pinned-target policy so regular web hides browser shortcuts without changing Electron defaults.
---------
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
* test(desktop): exercise packaged app startup
The previous smoke exited before IPC, window creation, and preload execution, so a broken packaged preload could still pass. Launch the normal app and observe its renderer bridge and managed daemon through CDP instead.
* test(desktop): preserve Linux sandbox in smoke
Configure the unpacked artifact's chrome-sandbox helper with the permissions Electron requires, while keeping sandboxing enabled so preload failures remain observable. Bound startup probes and include process logs in daemon timeout failures.
* ci(desktop): keep packaged smoke on releases
Remove the temporary pull-request artifact build after it validated the Linux launch path. Keep the real packaged smoke in the native desktop release matrix.
* ci(desktop): gate packaged smoke by changes
Run the Linux real-artifact smoke in pull-request CI when packages/desktop changes, while leaving the regular desktop unit-test matrix unconditional.
* fix(desktop): stop sandboxed preload from requiring a local module
The preload imported PASEO_BROWSER_PROFILE_PARTITION from the local module
./features/browser-profile.js. The preload runs in Electron's sandbox and is
tsc-compiled (not bundled), so that import emits require("./features/
browser-profile.js") at the top of preload.js. A sandboxed preload cannot
require a local module, so the require throws before
contextBridge.exposeInMainWorld runs. window.paseoDesktop is then undefined,
the app no longer detects itself as the desktop host, and it never starts the
built-in daemon (users had to run `paseo start` by hand).
The import was added in 0.1.108; 0.1.107 is clean, which matches the reports
that downgrading fixes it. See #2103.
Inline the one value the preload needs (the partition string) so it no longer
loads any local module, and keep browser-profile.ts as the source of truth for
the main process. Add preload-sandbox.test.ts, which parses preload.ts with the
TypeScript compiler API and asserts the only runtime module load is "electron"
(covering value imports, side-effect imports, re-exports, require, dynamic
import, and import-equals; type-only imports are ignored), plus a drift check
that the inlined literal matches the canonical constant.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* test(desktop): clearer preload drift-check failure message
Assert the inlined PASEO_BROWSER_PROFILE_PARTITION is present before comparing its value, so a missing/renamed constant reports "not found" instead of a misleading value-drift mismatch (Greptile feedback on #2111).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Adding or cloning a project now registers it and opens workspace setup
instead of creating a workspace implicitly. Keep Add Project independently
mounted from Search so closing one cannot control the other.
* Pin android-sdk to 21.0 and unify Java on 21 for mise
The `.tool-versions` `android-sdk latest` pin had resolved to the
ancient `1.0` cmdline-tools bundle, whose sdkmanager (3.6.0) predates
the `emulator` package and fails with `Failed to find package
emulator`. Pin `android-sdk 21.0`, point the hardcoded install paths
in `.mise.toml` at it, and add `cmdline-tools/21.0/bin` to PATH so
sdkmanager and avdmanager resolve.
Also unify the Java version on 21: `.tool-versions` already pinned 21,
but `.mise.toml` overrode it to 17. A local `npm run android` Gradle
build succeeds on 21.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Document iOS simulator and Android emulator local dev setup
Add local-dev docs for running the app against a worktree daemon:
- development.md: prerequisites for the ios-simulator preview service
(Xcode, an iOS runtime, automatic CocoaPods install), the
xcode-select/simctl troubleshooting fix, and a "Running the iOS app
on a local simulator" section covering `npm run ios` and pointing
the app at a worktree daemon via EXPO_PUBLIC_LOCAL_DAEMON.
- android.md: a "Prerequisites (local dev)" section (mise java 21 +
android-sdk 21.0, sdkmanager components, AVD creation) and a
"Running on an emulator against a worktree daemon" section covering
REACT_NATIVE_PACKAGER_HOSTNAME and EXPO_PUBLIC_LOCAL_DAEMON=10.0.2.2
for reaching the host daemon.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Give full Intel x86_64 emulator setup commands in Android docs
Greptile flagged that the arm64-v8a -> x86_64 substitution for Intel
Macs was only a parenthetical. Provide a complete, copy-pasteable
x86_64 command block instead of asking readers to hand-edit the arm64
one.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* fix(server): create autonomous turns for spontaneous ACP session updates
When an ACP-based provider handles background-agent completion
notifications, the resulting spontaneous session/update messages have no
active foreground turn to attach to. Mirror the Claude provider's
autonomous-turn mechanism so these updates are recorded in Paseo's
timeline.
- Start an autonomous turn when a sessionUpdate arrives with no active
foreground turn
- Tag timeline events with the autonomous turn id
- Complete the autonomous turn after a timeout, or before a new
foreground turn starts
* Fix ACP autonomous event scoping
---------
Co-authored-by: zab <b13022010527@gmail.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
* feat(desktop): share persistent browser data across tabs
Keep browser sign-ins and site storage across tabs and app restarts, with a Settings action to clear the shared profile.
* fix(settings): prevent duplicate browser data clears
* fix(desktop): complete browser data cleanup
Clear legacy per-tab profiles after upgrades and localize the browser data settings in every supported language.
* fix(desktop): close browser profile cleanup races
Individual tool outcomes remain visible in the expanded list. The aggregate header no longer treats a handled tool result as a failure of the overall work.
* fix(chat): allow failed turns to be forked
Failed turns can end in Paseo-generated timeline items without provider
message IDs. Anchor forks to canonical timeline positions while retaining
legacy boundaries for compatibility.
Place the fork summary before the new prompt and delimit it as structured
chat history.
* fix(chat): tighten fork boundary handling
* perf(app): reduce composer draft writes
Persist draft checkpoints every 200 ms during sustained typing while keeping the latest in-memory draft current. A trailing write stores the newest checkpoint when typing stops.
* test(app): cover draft persistence checkpoints
Keep the throttle policy deterministic at its storage boundary, including latest-value coalescing and pending-write cancellation when drafts are cleared.
* fix(app): flush pending drafts on background
Flush the latest throttled checkpoint when the app leaves the foreground and report asynchronous persistence failures. Extend the storage-boundary tests across consecutive intervals and explicit lifecycle flushing.
Native scrollbar appearance changed the textarea's usable width, feeding alternating measurements back into the composer height mirror. Reserve the gutter so wrapping and height remain stable.
* feat(subagents): archive finished children and show native names
Keep native provider sessions intact while letting users clear completed children from the track. Persist dismissed provider child IDs so history replay does not restore them.
* fix(subagents): cover persistence and naming edge cases
* fix(subagents): preserve active descendants and native names
* fix(subagents): retain dismissed timelines and trim paths
* fix(subagents): retain history across hydration
* test(subagents): expect normalized native descriptions
* fix(subagents): separate hidden rows from retained history
* fix(subagents): preserve restored hidden tabs safely
* fix(subagents): integrate archive with run state
* test(codex): expect normalized child name
* fix(subagents): retain dismissed tabs on reload
* fix(subagents): hide finished native agents locally
Keep the header action presentation-only so it does not mutate agent lifecycle or expand the runtime protocol.
* fix(subagents): humanize native agent names
* fix(subagents): preserve names in Codex history
* fix(subagents): retain hidden rows across reloads
* fix(app): stabilize desktop sidebar controls
Keep the left-window-controls toggle mounted across sidebar state changes so its position and tooltip state remain stable. Preserve browser placement and show an explicit Explorer close control only where native right-side controls do not provide one.
* fix(app): suppress inactive sidebar chrome
* fix(server): render image paths with spaces
Raw spaces in local Markdown image destinations made the renderer treat provider images as text. Emit encoded file URIs for spaced paths so the client parses and resolves them as images.
* fix(server): encode all absolute image paths
Provider image paths without spaces could still contain URI-significant characters such as fragment or query markers. Route every absolute local path through the encoded file URI boundary.
* fix(server): encode network image paths
Windows network-share paths bypassed the absolute local path handling. Normalize UNC and extended UNC paths before encoding them as file URIs.
* fix(app): preserve network image paths
Host-based file URIs were decoded as relative paths. Restore the UNC prefix so provider images on network shares resolve through the file RPC correctly.
* fix(server): preserve double-slash POSIX image paths
Classify only backslash-prefixed paths as Windows network paths so POSIX double-leading-slash paths retain their filesystem semantics.
* fix(server): prevent hidden Codex subagents and stuck turns
Codex can announce native children only through its mirrored lifecycle stream, while rejected interrupts previously looked like successful local cancellation. Preserve those child announcements and keep manager state active until the provider acknowledges cancellation.
* fix(server): block actions after rejected cancellation
A provider can accept a turn before publishing the turn ID needed to interrupt it. Keep that interval non-cancelable, and prevent reload, replacement, or rewind from proceeding without an acknowledged cancellation.
* fix(server): surface rejected agent cancellations
Replacement prompts and Stop requests could appear accepted after the provider kept ownership of the active turn. Complete replacement cancellation before detaching the stream, and return cancellation failures through the client response.
* fix(server): handle turn completion during cancellation
* fix: finish cancellation lifecycle handling
* fix(server): close remaining cancellation races
* fix(server): settle autonomous cancellations
* fix: honor cancellation failures at call sites
* refactor(server): centralize agent run state
* fix(server): keep pending runs isolated from stale events
* fix: surface remaining cancellation failures
* fix(server): narrow cancellation error handling
* fix(app): preserve desktop layout in half-screen windows
Treat native window controls as physical corner obstructions instead of deriving clearance from compact layout state. Keep 751px windows in desktop mode while preserving usable content when persisted sidebars are wide.
* fix(app): retain sidebar width across window resizes
* fix(app): yield app sidebar to narrow settings
* test(app): wait for mobile sidebar motion to settle
* fix(app): yield navigation to narrow workspace explorer
* fix(app): mark sidebar sizing helpers as worklets
* fix(app): hide app navigation when space is constrained
* feat(app): refine tool call summaries and scrolling
Keep summary groups stable from the first tool call, preserve live activity feedback, and scroll expanded groups to the latest entry. Consolidate web scrollbar styling around the native browser mechanism.
* refactor(app): reuse tool call height constant
* fix(app): preserve tool call summary state
* fix(app): refresh web tool groups on expand
* fix(app): preserve grouped tool errors
* feat(ui): improve changelog and help navigation
Make release notes easier to scan and reach from the app. Also keep the homepage marquee within the layout viewport so it cannot create horizontal scrolling.
* fix(website): reject duplicate release anchors
* fix(pi): complete locally handled prompts
Slash commands the Pi CLI handles without starting a model turn previously
left the Paseo turn hanging until interrupt. Parse the optional
agentInvoked correlation from prompt acknowledgements, surface
command_output/notify output as timeline content, and complete the turn
locally when no agent turn will start. Older Pi binaries without the
correlation are detected via a getState compatibility probe.
* review: guard command output without an active turn, simplify delegates
- Drop late-arriving command_output when no turn is active instead of
emitting an orphaned timeline item with turnId undefined
- Remove redundant 'text' in event check (type is already narrowed)
- Inline recordNoTurnNotification delegate
* refactor(pi): consolidate local prompt state
---------
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
* fix(workspaces): show provider subagent activity
Provider subagent lifecycle lives outside managed agent snapshots, so workspace aggregation dropped it after the parent turn finished. Count running provider subagents against the delegation root and emit workspace updates when their status changes.
* fix(workspaces): surface cross-workspace subagents
* Fix shortcut capture ignoring - = ; ' keys
KEY_MAP was missing Minus/Equal/Semicolon/Quote, so
keyboardEventToComboString returned null for those physical keys and
the capture UI silently dropped them. Add the four entries with their
shifted variants.
Also suppress the Electron View > Zoom accelerators (Cmd+-/=/0) while
capturing, so those combos reach the renderer instead of zooming the
window. Driven by the existing capturingShortcut flag via a new
paseo:menu:set-capturing-shortcut IPC.
* fix: address review feedback from PR #2047
Trim the capturing useEffect so it only fires when capture is active
(removes the redundant on-mount false and the double-fire on true->false),
and reset the main-process capturingShortcut flag on renderer reload via
browser-window-created + did-finish-load so a Cmd+R mid-capture can't
leave the zoom accelerators disabled.
---------
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
* fix(server): patch OpenCode SDK unhandled rejection on stream abort
The SDK's SSE abort handler calls reader.cancel() without handling
its rejection. When Paseo aborts the event stream during normal
session close, that promise can reject and crash the daemon with an
unhandled rejection outside any Paseo-owned code path.
Patches both copies the SDK ships (dist/gen and dist/v2/gen, the
latter being what Paseo actually imports). Extends
postinstall-patches.mjs to support patch-package running from a
non-root cwd, since @opencode-ai/sdk lives in packages/server's own
node_modules rather than the hoisted root.
* fix(server): stop assuming OpenCode's default agent is "build"
OpenCode users can rename or delete any agent, including the built-in
"build"/"plan" defaults. Paseo was injecting a hardcoded "build"
agent in several places whenever no mode was explicitly requested:
- The protocol manifest declared defaultModeId: "build" for opencode,
which AgentManager.normalizeConfig applied to every session
(including internal metadata-generation sessions) before any
provider-level logic ran.
- OpenCode's own normalizeOpenCodeModeId defaulted an empty/"default"
modeId to "build" rather than omitting the agent field.
- Unattended child creates (e.g. subagents spawned by an unattended
parent) forced modeId: "build" to express unattendedness, even
though that's already carried by the auto_accept feature.
Now an unset mode stays unset end-to-end: the "agent" field is
omitted from OpenCode prompt/command calls entirely, letting OpenCode
fall back to its own configured default agent instead of Paseo
guessing one that may not exist.
* fix(server): validate agent create mode on the WebSocket session path
resolveAndValidateCreateAgentMode already rejects modes unknown to a
provider's discovered mode list, but it was only wired into the MCP
create path. App-created agents (create_agent_request) skipped it
entirely, so a client's remembered mode preference — which can go
stale when a user renames or deletes OpenCode agents — sailed through
to the provider and failed mid-turn with an opaque error instead of a
clear rejection at creation time.
resolveSessionCreateAgent now calls
providerSnapshotManager.resolveCreateConfig, matching the MCP path,
so an invalid mode now throws 'Invalid mode ... Available modes: ...'
immediately on create.
* chore: address review feedback on create-agent and postinstall script
- Document the cleanup-ordering constraint in resolveSessionCreateAgent:
mode validation runs after buildSessionConfig (cwd isn't known until
that completes), so a thrown validation error leaves any
worktree/workspace buildSessionConfig created for the caller to clean
up. session.ts already handles this for the worktree path; the
directory-only workspace path has a pre-existing gap, not introduced
by this validation.
- Log spawn errors from patch-package in postinstall-patches.mjs
(e.g. ENOENT if it's missing from PATH) instead of failing silently.
* fix(server): don't fabricate OpenCode modes when discovery finds none
fetchModesFromClient and mergeOpenCodeModes fell back to
DEFAULT_MODES ([build, plan]) whenever OpenCode discovery returned
nothing — e.g. for a freshly-created worktree whose OpenCode server
hasn't loaded the project config yet. That fabricated list let
create-time mode validation accept a stale 'plan' preference that the
provider then rejects at prompt time (Agent not found: 'plan').
Return an empty list instead: OpenCode users can rename or delete any
agent, so any hardcoded fallback risks validating a mode that doesn't
exist. DEFAULT_MODES is retained only for description enrichment and
sort ordering.
* fix(app): reconcile stale selected mode against discovered modes on create
The mode picker displays modeOptions[0] when the stored modeId isn't
in the discovered list (e.g. a globally-remembered 'plan' that a
workspace's OpenCode config no longer defines), but the create request
still submitted the raw stored modeId. Result: UI showed 'Build' while
the request sent 'plan', which the daemon then rejected.
Reconcile the selected mode against the discovered mode ids when
building the create config (both the workspace draft composer and the
workspace-setup dialog), so the submitted mode matches what the picker
shows — falling back to the first available mode when the stored one is
absent. Mirrors the existing resolveEffectiveModel reconciliation for
models.
* test(server): fix opencode mode tests after fallback change + rebase
- Replace 'available modes include build and plan' (which relied on the
removed [build, plan] discovery-failure fallback) with two tests:
one asserting discovered agents map to modes, one asserting empty
discovery yields no fabricated modes.
- Restore messageId fields in the four provider-subagent timeline
assertions. These were accidentally dropped during rebase conflict
resolution; the #2013 subagent code legitimately emits messageId, so
the expectations must include it.
* test(app): fix e2e seed helper using invalid opencode mode
createIdleAgent seeded an OpenCode agent with modeId
'bypassPermissions' — a Claude mode OpenCode never had. This worked
before only because the session create path silently coerced unknown
modes to 'build'. Now that create-time mode validation rejects modes
the provider doesn't define, use 'build' + auto_accept (OpenCode's
unattended full-access equivalent), matching the rewind-flow helper.
Fixes the e2e failures across archive-tab, command-center-host,
settings-toggle-tab-regression, workspace-agent-tab-rename,
workspace-pane-remount, workspace-navigation-regression, and
worktree-restore specs, which all seed via this helper.
* fix(app): use global codebuddy binary for codebuddy-code ACP provider
The @tencent-ai/codebuddy-code npm package ships multiple bins
(codebuddy/cbc/cbc-prewarm), so `npx -y @tencent-ai/codebuddy-code`
fails with "could not determine executable to run" and exits
immediately. Paseo's ACP catalog probe treats npx as a long-running
process, so the initialize request never gets a response and the
probe times out after 60s, leaving the model list empty.
Switch to the user-installed codebuddy CLI (matching kiro-cli,
traecli, cursor, etc.) so the probe completes and models load.
* chore: trigger CI rerun (app-tests flaky: window-is-not-defined in strategy-web.test.tsx, unrelated to this change)
---------
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
* feat(app): add sidebar help and support menu
Give users a single place to run diagnostics, find shortcuts, and reach the support channels. Share one global diagnostic sheet between Settings and the sidebar so both entry points collect the same report.
* fix(app): keep cloned workspace navigation atomic
A newly merged clone flow still used the previous navigation signature and opened its draft tab separately. Route the cloned workspace and its draft target through the unified navigation command so attention selection cannot replace the intended tab.
* test(app): harden sidebar help coverage
Accept both the Discord invite URL and its canonical redirect, assert placement through real element geometry, and remove an unnecessary diagnostic-host callback.
* test(app): cover support redirect variants
Keep the help-menu E2E stable across prerelease versions and the valid redirect forms returned by Discord and GitHub.
* fix(ci): retry npm installs
Transient registry and package-download failures should delay a job briefly instead of requiring a manual rerun. Apply one bounded retry policy across CI, deploy, and release workflows.
* fix(ci): preserve retries for historical Android tags
Manual Android rebuilds may check out releases from before the shared retry script existed. Keep that install step self-contained and allow the shared helper's injected runner to be asynchronous.
* fix(ci): preserve retries for desktop release refs
* refactor(ci): use shared npm retry helper everywhere
* fix(app): focus explicitly opened workspace tabs
Explicit tab navigation could be overwritten by attention-aware workspace
navigation when the visible agent had just finished. Make workspace navigation
own both normal attention selection and explicit targets so callers cannot
sequence them inconsistently.
* test(app): strengthen forked tab focus assertion
* fix(codex): surface silent terminal activity
Successful no-output commands use a null output field, while terminal interactions carry their stdin separately. Preserve both so the agent timeline remains auditable.
* refactor(tests): share Codex event waiter
* fix(codex): preserve bounded terminal audit details
Keep terminal stdin through late command relabeling, cap it with other timeline content, and accept nullable output from legacy live completion events.
* fix(codex): preserve repeated terminal writes
* fix(app): preserve New Workspace prompts across target changes
The New Workspace draft was keyed by the selected host and project, so changing either target replaced the visible prompt with another draft scope. Treat the screen as one persistent draft surface and migrate the newest active legacy draft.
* test(app): express draft persistence as user actions
* fix(app): clear stale picker PR context
* fix(app): persist picker attachment ownership
* fix(app): keep workspace target context safe
* feat: clone GitHub repo into a workspace
Add an end-to-end "clone a GitHub repo and register it as a Paseo
workspace" flow: a new workspace.github.clone RPC, daemon handler,
client method, CLI `paseo clone` command, and a GitHub-repo mode in
the project picker modal. Gated behind the workspaceGithubClone
server capability flag.
- protocol: workspace.github.clone request/response schemas + feature flag
- server: handleWorkspaceGithubCloneRequest, normalizeCloneRepository
- client: DaemonClient.cloneGithubWorkspace
- cli: `paseo clone <repo> --dir <path> [--protocol https|ssh]`
- app: GitHub-repo mode, clone-protocol picker, error surfacing
Review fixes:
- CLI clone now checks the workspaceGithubClone capability and fails
fast with a clear "update the host" error instead of hanging for the
full request timeout against an older daemon.
- Replace the duplicated client-side URL-detection regex (app + CLI)
with a shared isCompleteGitRemote() in @getpaseo/protocol/git-remote,
backed by parseGitRemoteLocation so clients classify remotes
identically to the daemon (fixes confusing errors for git://, ftp://,
file:// inputs).
- Add git-remote.test.ts covering the shared classifier.
* Fix GitHub clone failure handling
---------
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
* feat(app): give each permission mode a distinct icon
The mode selector reused shield glyphs across modes: Auto mode showed a
question mark, Always Ask a checkmark, and the same icon appeared on
several modes (ShieldCheck on both Always Ask and Plan, ShieldAlert on
both Accept File Edits and Bypass). The result read as arbitrary rather
than as a scale of autonomy.
Assign every built-in provider mode a distinct icon from one shared
vocabulary, applied consistently across Claude, Codex, Copilot, and
OpenCode:
- Shield guarded default (every action prompts)
- ShieldCheck classifier/auto-reviewer vets prompts
- ShieldPlus edit tools pre-approved, others still prompt
- ShieldEllipsis plan / read-only deliberation
- ShieldOff fully permissive, no prompts
Register the new icons in the client MODE_ICONS map. Old clients
downgrade unknown icon names to ShieldCheck through the existing
customModeIcons compat gate, so the wire contract is unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(app): order permission modes by autonomy
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
* fix(chat): keep attachments visible after creating agents
Agent creation could receive the canonical user message before the client knew the new agent ID. Hand the local rich presentation to that message and preserve it through later timeline hydration.
* refactor(chat): clarify local message presentation naming
* fix(chat): clear pending state after message handoff
* feat(app): pin projects to top of the sidebar
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(app): dedicated Pinned sidebar section with UX fixes
Replaces the invisible float-in-place pinning with a Codex-style Pinned
section at the top of the sidebar that hoists pinned chats and projects.
- Pin-aware keyboard shortcut numbering (badges match visual order)
- Pinned chats show their project name as subtitle (context after hoisting)
- Pinned entries ordered most-recently-pinned first
- Collapsible Pinned section (persisted)
- Double-click guard on workspace pin toggle
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(app): move Pinned section above the Workspaces header
Pinned now sits at the top of the scroll, above the "Workspaces" section
header (which moves into the scroll below it), matching the market-standard
sidebar layout. Chevron sits beside the "Pinned" label.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(app): reveal Pinned collapse chevron on hover only
Extracts PinnedSectionHeader with its own hover state; the chevron hides
until hover on web and stays visible on touch/compact.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(app): pin the active chat via keyboard shortcut and inline pin button
Adds a ⌘⇧P / Ctrl+Shift+P shortcut that pins the selected chat, mirroring
the worktree.archive action across actions/dispatcher/route-shortcut, gated
by `selected && supportsWorkspacePinning`. Also surfaces an inline pin button
on row hover (always-on for touch) alongside the kebab in the shared trailing
overlay, swapping Pin/PinOff by pinned state.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(server): unify project and workspace pin handlers
Collapses the two near-identical pin.set handlers into a shared
handlePinSetRequest that owns control flow, error handling and the
workspace-update fan-out; each caller only supplies its registry apply
step and typed response payload.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(app): drop empty pinned project shells and empty Workspaces header
Pinning every chat of a project hoisted them into Pinned but left an empty
project header (plus its new-workspace ghost row) below, duplicating the
project. Skip projects emptied by pinning while keeping genuinely empty
projects. Also hide the "Workspaces" section header when no unpinned projects
remain, so it no longer floats above nothing.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(protocol): correct workspacePinning COMPAT version to v0.1.105
Feature ships in 0.1.105 per the changelog, not the placeholder v0.1.103.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(app): hide redundant chat pin control inside pinned projects
A pinned project already hoists all its chats to the top, so the per-chat pin
was a no-op that just toggled a dead icon. Suppress the inline pin button, the
kebab pin item, and the pin keyboard shortcut on chats whose project is pinned;
they still work for chats in unpinned projects.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(app): hide project pin control on empty projects
Project pin state is derived from workspace descriptors, so a project with no
workspaces would set pinnedAt on the server but emit no descriptor update — the
pin silently never surfaced and the user got no feedback. Offer the pin only
once the project has a chat, matching how chat pins are suppressed inside
already-pinned projects.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(app): drop collapsed Pinned rows from keyboard shortcut numbering
When the Pinned section is collapsed its chats and projects are hidden, but the
shortcut model kept counting them: visible unpinned rows showed off-by-N badge
numbers and pressing a number key jumped to a hidden pinned chat. Exclude the
pinned rows from numbering when Pinned is collapsed, matching what renders.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(app): hydrate pinned sidebar rows
* fix(app): keep filtered workspace controls reachable
* feat(app): scope sidebar pinning to chats, drop project pinning
Per PR review: pinning whole projects overlapped with project reordering
and mixed projects into the pinned list. Keep chat pinning only.
- Remove the project.pin.set RPC, its server handler, the projectPinnedAt
descriptor field, and the client setProjectPinned method.
- Drop the project pin control, isProjectPinned reads, and the suppressPin
plumbing (its only source — a pinned project — is gone).
- Fold pin-aware shortcut ordering into SidebarModelProvider (the live
shortcut owner) and delete the orphaned use-sidebar-shortcut-model hook,
so pinned chats and their shortcut numbers share one projection.
- Prune the now-unused project pin i18n keys across all locales.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(server): inline the pin handler into the workspace path
With project pinning gone, handlePinSetRequest had a single caller. Fold it
back into handleWorkspacePinSetRequest and drop the project/workspace
generalization.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* revert: keep main's use-sidebar-shortcut-model untouched
This hook (and its test) already live on main. The scope-down had deleted
them, but that's out of scope for this PR — the pin-aware shortcut ordering
lives in SidebarModelProvider now, so main's hook is left exactly as it was.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore(protocol): align workspacePinning COMPAT version in websocket-server
Match the v0.1.105 version already corrected in messages.ts.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(app): reshape pinned sidebar updates
* chore(app): remove stale pin button styles
* refactor(app): simplify pinned workspace rows
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
* fix(codex): hide shell launchers from command summaries
Codex only unwrapped POSIX shells from /bin. Commands launched through /usr/bin/zsh therefore exposed the wrapper in the tool-call row.
* test(codex): preserve shell wrapper path coverage
* fix(app): keep stream controls clickable under scroll button
The full-width animated overlay won browser hit testing even outside the circular control. Keep positioning on a pass-through container and animate only the button-sized child.
* test(app): preserve scroll hit-test diagnostics
* test(app): align scroll hit-test fixture deterministically
* test(server): include OpenCode message IDs in subagent assertions
Selectable table cells each installed a window-level gesture recognizer, causing every touch to fan out across the entire table. Keep prose selectable while rendering iOS table text without selection.
New terminals were always spawned by the daemon at the hardcoded 80x24
default and only resized once the client's first resize arrived. On a
slow first mount that leaves a window -- or a stuck state -- where a new
terminal renders far smaller than its pane (e.g. vim in a corner).
Seed the PTY with the client's measured viewport size at creation time
so it is born at the right size and the race window is gone.
- protocol: optional `size` on create_terminal_request. Old daemons
ignore it and keep 80x24; old clients send nothing. Fully backward
compatible, no capability gate.
- client: a small last-measured-size cache keyed by serverId+cwd (every
terminal in a workspace shares the pane) with a most-recent fallback;
written on fit, read at create.
- server: thread rows/cols through the controller, terminal manager, and
the worker-thread boundary into the existing size-aware createTerminal.
Tests: cache logic, protocol back-compat parse, controller forwarding.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* fix(chat): show fork controls for every provider
Real assistant messages require stable identity so provider-agnostic chat actions can address the selected turn. Preserve provider IDs where available and generate stable adapter-owned IDs otherwise.
* test(opencode): expect assistant message identities
* test(pi): cover assistant updates without message start
* fix(providers): preserve late assistant identities
* fix(opencode): track fallback compaction identities
* fix(server): bound shell tool output in timelines
Provider tool output could enter timeline persistence and live streams without a size limit. Bound canonical shell output before coalescing, storage, history hydration, and dispatch so every provider follows the same 64 KiB budget.
* refactor(server): slice oversized tool output directly
* fix(server): cover imported and failed shell output
When running Paseo on the Paseo repo itself, the worktree setup step and the
paseo.json service scripts (daemon/app/desktop/ios-simulator) fail with
"cross-env: command not found" unless cross-env is installed globally. The
daemon runs paseo.json commands in a plain shell without the project's
node_modules/.bin on PATH (unlike npm run), so the bare cross-env (a local
devDependency) does not resolve.
cross-env only exists for Windows cmd compatibility, and every one of these
commands launches a unix ./scripts/*.sh, so it was not buying anything here.
Drop it and set the env vars inline. The cross-env dependency stays -- it is
still used by the npm run scripts where it is actually needed.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Nested native stacks fell back to the navigation library's light background during cross-stack swaps. Share the active app surface color across every stack without subscribing route layouts to all theme runtime changes.
* feat(app): expose archive for every workspace
Workspace actions now archive one workspace by id. The daemon remains responsible for backing-specific cleanup, including removing a managed worktree only after its final workspace is archived.
* refactor(app): clarify archive descriptor name
* fix(app): preserve workspace archive safety
* fix(app): allow archive without upstream
* refactor(app): remove stale archive callback
* test(app): cover workspace archive ownership
* fix(server): stop reconnects reviving agents during shutdown
Shutdown previously took its agent snapshot while reconnects and in-flight registrations could still add or repersist agents. Gate ingress and registration first so closed agents cannot return during teardown.
* fix(server): drain late sessions during shutdown
* fix(app): preserve loader behavior in retained panels
Keep the running footer mounted across panel switches. Inactive loaders stop their animation work, then realign to the original wall-clock phase before they become visible.
* perf(app): remove inactive loader wrapper worklet
* perf(app): pause hidden turn elapsed timers
* perf(app): reduce loader animation work
Keep one wall-clock-aligned clock for visible loaders and detach retained
hidden loaders without unmounting them. Preserve the 950 ms sequence and
reduced-motion behavior.
Plain chat views started the history virtualizer even without virtualized rows. Its delayed scroll callback could outlive the mounted view and make the app test job fail during environment teardown.
* fix(app): stabilize streamed chat rendering
Use deterministic Markdown AST keys and preserve unique row identities when an assistant message resumes after a tool. Keep native gesture and plan-card children correctly keyed to avoid unsupported Fabric reconciliation paths.
* fix(app): stabilize retained native panels
Keep retained workspace roots in a stable native order and make active
panels present in the selection commit. Hidden panels now stop expensive
subscriptions and animations without losing mounted state.
* fix(app): harden retained panel activity
Project picker tests had depended on a placeholder changed for fuzzy search. Use a dedicated input test ID so product copy no longer breaks the selectors.
* fix(codex): keep parent turns active during sub-agent work
Codex multi-agent activity is thread-scoped. Treating child events as root
activity could end the foreground turn early and splice child output into
parent messages.
Route legacy and current activity shapes through stable child identities and
preserve message boundaries across live and resumed turns.
* fix(codex): preserve sub-agent details across stream chunks
Keep empty activity paths explicit and normalize the synthetic assistant
boundary for every chunk in the active message.
* fix(codex): route legacy child events by thread
* fix(codex): stabilize sub-agent activity lifecycles
* fix(codex): retain child tool activity and terminal state
* Hide keyboard shortcut badges in workspace menu on native
The archive item's shortcut badge (e.g. Ctrl+Shift+⌫) rendered in the
workspace kebab/context menu on mobile, where there's no physical
keyboard to invoke it. Gate the trailing badge behind !isNative in both
kebab menu variants.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(app): hide native shortcuts in every workspace menu
Workspace menus were duplicated across project and status views, so the status path missed the native gate. Centralize the menu so every sidebar view shares the same shortcut policy.
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* perf(app): keep large sidebars responsive
Sidebar rows rescanned every agent for each session update, multiplying work with both workspace and agent count. Build workspace activity once and share the derived sidebar model so updates scale with the underlying collections.
* test(app): use valid agent fixture for workspace restore
* refactor(app): keep sidebar model surface focused
* fix(app): keep mobile sidebars synced with store
Gesture-owned skip flags could swallow the next store sync after a no-op or overlapping gesture, leaving React state and Reanimated shared values split. Always reconcile both mobile sidebars from the panel store after gesture animations.
* test(app): keep sidebar sync coverage pure
* fix(app): keep sidebar close transitions idempotent
* fix(app): keep mobile sidebars stable through interruptions
Independent React and worklet state let stale gesture or animation callbacks
overwrite newer panel commands. Use one revisioned target and one transient
motion runtime so outdated callbacks cannot restore an earlier panel.
* fix(pi): avoid raw images for text-only models
Pi text-only models reject image content, and Pi persists the rejected raw image in session history. Materialize those image prompts to local files and send path hints while preserving raw images for vision-capable models.
* fix(pi): require explicit vision support
Default unknown Pi model capabilities to the text-hint path so raw image forwarding only happens for models that declare image input. Also keep materialized image files in a private per-user temp directory with restrictive file modes.
* fix(pi): harden materialized image temp dirs
* test(server): accept per-user image attachment dirs
* fix(server): use private temp dirs for materialized images
* fix(server): refresh missing image temp cache
* test(server): clean image temp dirs
* feat(app): restore compact Changes diff controls
Keep the folder tree behind a view toggle while preserving the flat file list as the default.
Move secondary diff options into the overflow menu so the Changes toolbar stays compact.
* fix(app): mirror whitespace diff option copy
* fix(app): polish diff controls review follow-up
Move canFetch guard before first await so disabled refresh bails out synchronously instead of entering async path. Add .catch() swallow for fire-and-forget call to prevent unhandled promise rejections when react-query cancels pending queries during rapid tooltip open/close cycles.
* fix(pi): add get_session_stats fallback from get_state for old OMP binaries
Older Oh My Pi binaries don't support the get_session_stats RPC command,
leaving session usage stats blank in Paseo. This adds a compat layer in
cli-runtime.ts where getSessionStats() tries get_session_stats first, then
falls back to extracting context window usage from get_state.
The fallback provides contextUsage.tokens and contextWindow even when the
binary lacks full token/cost reporting — enough to keep the context meter
working. Token counts and cost still require the newer RPC.
Added three tests:
- Fallback triggers when get_session_stats is unsupported (throws)
- No fallback when get_session_stats returns valid data
- Returns empty object when both commands fail
* fix(pi): use nullish check instead of truthy for cost field in getSessionStats
Group changed files in the Changes view into a collapsible directory
tree instead of a flat list, on every form factor — consistent with the
Files explorer, which already renders a directory tree on mobile.
Folders are collapsible and expanded by default, single-child directory
chains are compressed into one row (packages/app/src), and folder rows
show aggregate +/- stats.
Client-only, built entirely from the existing ParsedDiffFile paths:
- diff-tree.ts: pure build/compress/flatten of the directory hierarchy,
keyed by full uncompressed dir path so collapse state survives
compression changes as the diff mutates.
- diff-flat-items.ts: pure buildDiffFlatItems + sumHeightsBefore. The
function retains a flat mode (showTree=false) as a tested seam for a
possible future flat/tree toggle; sticky header indices are
file-headers-only, derived from the post-collapse list.
- panel-store: diffCollapsedFoldersByWorkspace slice (collapsed-set, so
folders default to expanded), persisted via version 12. The set is
reconciled against the current diff so folders that leave and reappear
default to expanded and the stored set stays bounded.
Presentation primitives (chevron, indent guides, indent constant) are
extracted into components/tree-primitives and shared with the Files
explorer, which drops its private copies (and a banned useUnistyles
call site). The two trees now render identical rows; only the
tree-building stays separate (server listings vs. diff paths).
The toolbar's expand-all stays file-diffs-only (renamed
allFileDiffsExpanded); a separate control collapses/expands all folders.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* fix(android): handle denied voice audio focus
Android can deny voice-mode audio focus while another system audio owner, such as an incoming call, is active. Treat that as an interruption so resume does not crash the app and JS stops voice mode coherently.
* fix(voice): keep interruption state consistent
Make native interruption handling payload-aware so iOS resume events do not stop the shared JS runtime, avoid ending voice mode for duck-only Android focus changes, and roll host voice mode back if capture fails after startup enabled it.
* fix(voice): avoid duplicate interruption handling
Keep exactly one Android audio-focus request active at a time and leave JS capture state untouched for non-terminal interruption events.
* fix(android): abandon blocked audio focus requests
When Android denies or blocks voice audio focus, abandon the pending focus request so delayed focus gain cannot arrive after voice mode has already stopped.
* fix(voice): handle interrupted dictation capture
Stop Android resume from replaying after recording restart fails, and propagate native blocked interruptions into dictation so users do not remain in a stale recording state.
Daemon-advertised hostnames still name new/default hosts, but connection refreshes and re-pairing no longer overwrite labels that users already changed.
Drop advertised MCP output schemas (no supported harness forwards them to the model) and remove the redundant wait_for_agent tool. Core agent tool catalog drops ~20.5k -> ~5.6k estimated tokens; ~24.9k -> ~9k with browser tools. Runtime structuredContent is preserved via server-side text serialization. Also corrects the agent-wait timeout guidance for blocking callers.
* feat(settings): consolidate daemon management into the host page
The app-level Daemon section and the localhost host page rendered the
same daemon card in two places. The host page is now the single home:
the Host sidebar group leads with Overview, and a desktop-only "Enable
built-in daemon" option in the host switcher recovers a removed
localhost connection. Old settings/daemon deep links redirect
(COMPAT-tagged).
* fix(settings): hide daemon recovery option when the server id probe fails
Review feedback: !query.isLoading treated an errored IPC probe as
resolved, so the recovery affordance could show without knowing the
local server id. Replace the resolved-boolean hook with a single
discriminated state hook (loading | error | resolved) — errors hide the
affordance and fall back to the settings root in the redirect. Also
collapses the two query subscriptions per caller into one.
* fix(settings): keep daemon recovery reachable when no hosts remain
Review feedback: removing localhost as the only host takes the settings
sidebar down its no-host branch, which never mounts the host picker —
so the "Enable built-in daemon" option was unreachable in the most
common recovery case. The sidebar now owns the single hook call, passes
it to the picker, and renders the same action as a sidebar row next to
"Add host" when no hosts exist.
* test(e2e): update settings host header to Overview
Test drift: the host section's sidebar/header label was intentionally
renamed to "Overview"; the spec still asserted the old "Host" title.
Also assert the retired app-level Daemon row stays absent.
* feat(schedules): per-run workspaces, isolation and archive controls
Scheduled agents never appeared in the sidebar: each schedule reused one
stamped workspace and archived the run's agent on completion. Now every run
mints its own workspace — the entity the sidebar shows — with per-schedule
controls for isolation (local or worktree) and whether to archive that
workspace when the run finishes.
The schedule form is rebuilt on a non-React form model (open/commands/close)
to end the flicker, edit-into-create contamination, and multi-host hydration
bugs that came from effect-choreographed shared state. That lands the reusable
foundations it needed: a form kit over a single control-geometry owner, a
data-access taxonomy (replica/snapshot/fetch) with real load-state semantics,
and lint/boundary guardrails so new screens inherit the paved road.
Also fixes desktop combobox popovers truncating options when the trigger is
narrower than the content. New schedule protocol fields are optional on both
create and update, so old and new clients/daemons stay compatible.
* test(app): complete mock themes for control-geometry tokens
switch and terminal-profile-edit-modal now style through
createControlGeometry, which reads theme.borderWidth[1], theme.opacity[50],
and theme.colors.borderAccent. The hand-rolled mock themes in these two
suites lacked those tokens, so the eager StyleSheet.create mock threw at
import (collection error), not on any assertion. Add the missing tokens.
* fix(schedules): address review findings on run cleanup, edit isolation, load error, sheet dismiss, and preference hydration
- Server: archive the run's workspace even when agent creation fails, so a
misconfigured provider no longer orphans a sidebar workspace/worktree.
- Edit form: keep a stored worktree isolation while worktree eligibility is
still resolving, instead of silently downgrading a saved schedule to local.
- Schedules screen: show the load error/retry UI when every host fails, rather
than spinning forever behind the loading branch.
- Form sheet: fire the parent onClose on native gesture/backdrop dismiss so the
sheet closes instead of re-opening.
- Form model: apply late-loading saved preferences to untouched create-mode
fields (never to edited schedules or user-modified fields).
* fix(schedules): review batch — reconnect resubscribe, worktree prune, Android dismiss, cadence and provider edit safety
- Push-router: re-send terminal and checkout-diff subscriptions after a
reconnect; the old per-hook effect resubscribed on isConnected and the
extracted router did not, so daemon restarts silently stopped terminals_changed
pushes (root cause of the terminal-activity e2e failures).
- Server: pass the run's source repo root when archiving worktree runs so the
worktree is unregistered (git worktree remove/prune), not just deleted; also
archive the workspace when agent creation fails even with archive-off (an
agentless workspace has nothing to inspect).
- Sheet: RN Modal onDismiss is iOS-only — notify dismiss once-per-close on the
Android native path too; replace the JSDOM component test with a real
Playwright dismiss spec and inline the trivial dismiss decision.
- Form model: editing an interval schedule no longer rewrites its cadence to
cron unless the cadence UI is touched (90-minute intervals have no cron
equivalent); switching projects clears stale provider/model state and gates
submit until the new host's snapshot resolves.
* chore(lint): drop custom lint wrapper and boundary script, plain oxlint
The import bans stay in .oxlintrc.json (oxlint-native no-restricted-imports).
The receiver-sensitive checks the custom script enforced are not worth a
parallel lint pipeline; if they come back it will be as oxlint rules.
* fix(schedules): crash-safe run cleanup, capability-gated run options, resilient mutations
- Persist the run's workspace/agent ids on the running-run record so a daemon
crash mid-run can be recovered: restart archives the interrupted run's
workspace under the same policy as normal cleanup (agentless always archives,
otherwise archiveOnFinish is respected).
- Hide Archive on finish (and omit archiveOnFinish/isolation from payloads) on
hosts without workspaceMultiplicity — an old daemon ignores the fields, so
offering the switch there would lie.
- Optimistic pause/resume/delete no longer throw when the schedules cache holds
a still-connecting entry alongside loaded data.
- Mode field is gated on provider mode options, not a selected model, so
model-less providers can pick their advertised modes.
* fix(diagnostics): include desktop app logs
Settings diagnostics now collect the Electron main-process log separately from daemon.log so desktop startup and environment-probe failures are included in app reports.
* refactor(desktop): share log tail reader
* fix(diagnostics): surface desktop app log read errors
* refactor(diagnostics): tighten desktop report API
* fix(desktop): retry login shell env resolution
Finder-launched desktop sessions can inherit launchd's minimal PATH if the login shell probe times out or never prints its marker. Retry once with a non-interactive login shell so profile PATH still has a chance to apply.
* fix(desktop): cap login shell probe retry budget
* fix(desktop): repair csh login env retry
* test(desktop): pin login shell env retry platform
* refactor(desktop): use object params in shell env helpers
* Fix packaged web UI dist dir resolution
* Fix bundled web UI path typecheck
* Fix packaged web UI path matcher
* Remove duplicate packaged web UI clause
* Merge packaged web UI path branch
* Use bundled web UI artifact existence
* Inline bundled web UI existence checks
* Restore original web UI fallback branch
* Reuse app-dist path in web UI resolver
* Format web UI resolver tests and config
* Tidy web UI resolver follow-up
* fix(server): tighten bundled web UI path resolution
---------
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
* fix(worktree): preserve PATH for lifecycle scripts
Lifecycle command strings were running through login shells, which could rewrite PATH before setup commands saw the daemon environment. Use the shared stable script-shell helper without login startup files, and route related project-authored command strings through it.
* fix(worktree): ignore Bash startup env hook
Project command strings should not source shell startup hooks before they see Paseo's supplied environment. Strip BASH_ENV from lifecycle, loop verify, and ACP string-command shells and pin the behavior in setup tests.
* fix(worktree): resolve Bash through PATH
* fix(worktree): preserve Windows cmd command strings
* test(acp): expect Windows cmd terminal strings
traecli publishes its slash commands and skills asynchronously via the standard
ACP available_commands_update notification ~400ms after session/new resolves.
Because the catalog entry runs through the plain GenericACPAgentClient
(waitForInitialCommands defaults to false), listCommands() resolves before that
first batch arrives and the Paseo UI shows an empty slash menu — intermittently,
depending on whether the menu is opened inside the ~400ms race window.
Add a thin TraeACPAgentClient that sets waitForInitialCommands: true (10s
timeout) and wire it into the extends:"acp" derived-provider branch, mirroring
CursorACPAgentClient exactly. Cursor has the identical async-commands behavior;
this reuses that adopted pattern rather than introducing anything provider-
specific. Unlike Kiro (#1792), traecli uses the standard available_commands_update
update type, so no extensionCommandsParser is needed.
* feat(browser): generalize browser automation hosts
Rename the browser automation client capability to a host-neutral payload, advertise supported commands, and route tab-scoped commands by browserId affinity so future browser hosts can register without another protocol shape change.
* fix(browser): harden browser host registration
Reject unusable browser host capabilities, clean up pending requests when a host registration is replaced, and defer list_tabs affinity updates until every host in the aggregation succeeds.
* fix(browser): preserve host tab affinity across reconnects
Keep stranded tab ownership tied to the disconnected host so the same host can reclaim open tabs after reconnect, and tolerate future command names in browser_host capability payloads while retaining only known commands.
* fix(docker): build images from source
Collapse the npm-install and source-build image paths into one Dockerfile so every Docker image is built from the checked-out workspace packages. Include procps in the runtime image because provider process cleanup depends on ps through tree-kill on Linux.
* fix(docker): include native speech packages
* fix(docker): cover source build inputs
* fix(docker): ignore local agent artifacts
* fix(browser): stop new MCP browser tabs stealing focus
browser_new_tab used openTabFocused, switching the workspace UI to
the new tab and interrupting whatever the user was looking at.
Automation commands don't need visual focus to work: webviews stay
resident and paintable regardless, so open the tab in the background
via openTabInBackground instead.
* fix(browser): stop background MCP tabs hijacking Reload
openBrowserTabForRequest still called setWorkspaceActiveBrowser for
tabs opened in the background, so the desktop Reload/Force Reload
menu (which targets getMostRecentWorkspaceActivePaseoBrowserWebContents)
could reload an agent's hidden tab instead of whatever the user was
looking at. Automation commands never needed this: every tab-scoped
MCP tool takes an explicit browserId, and the existing
useSyncWorkspaceActiveBrowser hook already keeps "active browser" in
sync with real UI focus. Drop the redundant call.
* fix(providers): show Claude Ultra Code by capability
Expose Ultra Code for xhigh-capable Claude models and keep first-party Claude model metadata in one manifest so future model updates do not need scattered feature lists.
* fix(providers): preserve Claude fast mode aliases
Normalize versioned Claude model IDs through the manifest before checking fast-mode support so dated Opus IDs keep the same feature surface as their base model.
* fix(providers): keep Claude fast mode first-party
Anchor Claude model normalization so custom settings models that merely contain first-party IDs do not inherit manifest-only fast mode.
* fix(providers): preserve Claude runtime aliases
Keep Fast Mode capability checks strict to manifest IDs while allowing provider-form Claude runtime model strings to resolve known model metadata.
Resident nodes restored from the parking host could carry stale offscreen styles after hot reload, so reapply the permanent parking path before returning them.
Delete the renderer capture prep handshake and keep screenshots on the serialized invalidate/retry path. The capture harness now defaults to the production P1 attach-off parking check.
Element annotation is now free-text: dropped the Fix/Change/Question/
Approve intent pills and the intent field, so the user just says what
they want. The card uses the shared Button and is titled "Annotate
element".
Toolbar: the element picker and DevTools are no longer dev-gated and
work in prod; DevTools uses a wrench icon, and the old clipboard "grab"
is now a Camera "Screenshot element" tool. Each toolbar button
highlights only its own mode, and the copy toast reflects whether a
screenshot image was actually captured.
Browser tools were registering union output schemas that the MCP SDK could not validate at call time. Use raw object output shapes and cover the real tools/call boundary.
Move the New schedule button out of the screen header (the only screen
using MenuHeader rightContent) into the content filter row, and match the
History screen's full-width layout instead of the 720px centered column.
Tighten the shared sm SegmentedControl and Button vertical padding so the
status switcher and button sit flush with the host-filter pill; this is a
global sm change and also affects settings and the schedule form sheet.
* fix(app): show New workspace action on non-git sidebar projects
The per-project "New workspace" affordance in the sidebar (the project-
header + button and the empty-project ghost row) was gated on
host.canCreateWorktree, i.e. projectKind === "git". Non-git projects
(non_git / directory) therefore showed no way to add a workspace, even
though a host with the workspaceMultiplicity capability can create
additional local workspaces for them.
Thread the per-host workspaceMultiplicity flag into the sidebar project
row model and show the affordance when canCreateWorktree ||
supportsWorkspaceMultiplicity, matching the gate already used by the
global "New workspace" button and the Cmd+N handler. Rename the internal
trailing-action kind new_worktree -> new_workspace (a client-only UI
model, not a wire type) to reflect that it now also covers non-git
workspaces.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* test(app): expect the per-row New workspace icon on non-git projects
The sidebar "+" now shows for non-git projects on a multiplicity-capable
host, so the Model B sidebar spec no longer asserts the non-git project
has zero new-worktree icons — it now expects the icon, like git projects.
Also refresh the now-stale "no new-worktree icon" comments in the
entry-points spec (the picker is still one valid entry point).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* feat(schedules): polish schedules list and editing
Adds host filtering, status grouping, and target resolution to the
schedules screen. Extracts a shared HostFilter from sessions. Improves
schedule editing with project targets and model hydration. Handles
missing targets server-side instead of retrying forever.
* test(mcp): update schedule mocks for createOrReplace
* fix(schedules): only show host name when multiple hosts exist
The Anthropic usage API returns resets_at: null when a window has no
active session. The schema only allowed string/undefined, so the parse
threw and the whole Claude provider surfaced as an error in the quota
panel. The consuming code already handled null; only the schema was
stricter than the API.
* feat(app): add ByteDance TRAE CLI to the ACP provider catalog
Adds the official ByteDance TRAE CLI (traecli) to the in-app ACP provider
catalog. traecli is ACP-native, so Paseo drives it over the standard ACP
transport via `traecli acp serve`, reusing the generic ACP client exactly
like Kiro, Qoder, Cursor, and Gemini.
- catalog: traecli entry (command ["traecli","acp","serve"], manual install)
- icon: vendored TRAE monogram SVG + registered icon name
- docs: supported-providers list + CHANGELOG
- tests: focused command + icon-name assertions
Verified against the real traecli ACP surface in multica-ai/multica#4724.
* feat(app): use the real TRAE app logo for the traecli provider icon
* docs: drop TRAE CLI changelog entry
---------
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
* fix(app): keep composer autocomplete visible after route hops
Workspace callers now express only the target workspace. The navigation helper chooses whether to pop to a mounted host route or fall back, and active-workspace tracking ignores stale hidden route params while app-wide routes are foregrounded.
* test(app): address workspace navigation review
* fix(app): parse decoded legacy workspace routes
* fix(app): ignore decoded legacy tab routes
* test(app): use canonical offline workspace route
* fix(app): preserve new workspace drafts across archive
The New Workspace project picker was still tied to the last active workspace. Archiving that workspace could make the picker fall back to another project, changing the draft key and clearing the composer.
* refactor(app): extract new workspace project selection
Keep the archive-draft fix out of the screen effect by moving the project selection transition into a small pure model with focused coverage.
* test(app): seed host in archive draft regression
Make the new Playwright regression self-contained by seeding the local host before opening the app shell.
* refactor(app): home new workspace project picker state
* fix(app): reset project picker on capability hydration
* fix(app): preserve manual project picks during hydration
* fix(app): keep project selection through archive gaps
* fix(app): narrow archive-gap project preservation
* fix(app): refresh new workspace project selection
* feat(app): open a project with Cmd+O
Cmd+O now opens the project picker (was Cmd+Shift+O). New worktree,
which previously used Cmd+O, no longer has a keyboard shortcut; its
sidebar button still creates one.
* test(app): assert old Cmd+Shift+O open-project binding is unbound
Locks in the rebind to Cmd+O so re-adding a Cmd+Shift+O binding would fail CI.
* fix(app): keep Open project override id stable and forward Cmd+O in desktop browser
- Binding ids for Open project keep their original names so existing user
shortcut overrides (keyed by binding id) survive the Cmd+Shift+O -> Cmd+O rebind.
- Forward "o" from focused browser webviews in the desktop app so Cmd/Ctrl+O
reaches the renderer and opens the project picker there too.
* feat: add Schedules screen to manage scheduled agents
Adds a Schedules section in the sidebar header (below Sessions) for viewing
and managing new-agent schedules: recurring jobs that spawn an agent on a
cron or interval cadence.
- Borderless table showing provider icon, model, cadence, next run, and
status, with row actions to edit, pause/resume, run now, and delete.
- Create/edit sheet that reuses the agent provider/model/mode/working-
directory pickers, plus an interval/cron cadence editor with presets,
validation, and a UTC preview.
- Responsive: hover-revealed row actions on desktop, an always-visible
action menu on mobile.
- Data layer over the existing schedule RPCs (list/create/update/pause/
resume/delete/run-once) with optimistic pause, resume, and delete.
Scope is limited to new-agent schedules for now.
* Make the schedule form controls visually consistent
Merge the provider and model pickers into the single nested selector the
draft screen uses, render it as a full-width field, mute the placeholder,
proportion the cadence segmented controls, unify the cron preset chips,
and route the sheet scroll through the themed scrollbar.
* Address schedules review feedback
* Translate schedule hook client fallback errors
* Make schedules global and project scoped
* Stabilize project settings transport E2E
* Reset schedule model on cross-host project change
* Reject malformed cron step expressions
* Tighten schedules project query freshness
* refactor(schedules): clean up review feedback
* test(schedules): extract fake host setup
* refactor(schedules): share cron expression parsing
Use one structural cron parser for app previews and daemon cadence validation so the accepted grammar cannot drift between surfaces.
Remove the unused host-scoped schedules redirect while keeping the global schedules route.
* feat(browser): inspect, annotate, and grab page elements for the agent
Build a design-review flow on top of the in-app Electron browser so users
can send page elements to their coding agent with context.
- Annotate: pick an element, write a comment, choose an intent
(fix/change/question/approve); the element context + intent + comment go
to the agent as text, and a cropped screenshot rides along as an image.
- Grab: pick an element to copy its info + screenshot straight to the
system clipboard (no comment), with a toast on success/failure.
- Hover inspector: in select mode each element shows a floating label with
tag, id/class, React component name, and pixel size.
- Page markers: annotated elements on the current page get numbered badges
that track scroll/resize.
- Device sizes: a viewport-size menu (responsive + 13 common device presets)
renders the page centered in a fixed-size frame.
- Toolbar buttons now expose hover tooltips and no longer get squeezed out
on narrow panes; the element selector is available to all desktop users
(previously dev-only).
Screenshots and clipboard writes go through new Electron main-process IPCs
(capturePage + clipboard) so they work regardless of webview focus. Element
screenshots are referenced by id in the workspace attachment store and are
protected from the draft-store attachment GC. All new strings are
translated across the six supported locales.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(browser): write element clipboard exactly once
Resolve the image before writing so a combined text+image grab no longer
does a redundant text-only writeText() first (which flashed an intermediate
clipboard state). Addresses greptile review feedback.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* i18n: add browser annotate/grab/devices strings for ja and pt-BR
main added Japanese and Brazilian Portuguese locales after this branch was
created; add the browser annotation, grab, and device-size keys to keep all
locales in sync with en. resources.test.ts parity passes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Add opt-in browser tools for desktop tabs
Adds the daemon opt-in, desktop tab routing, MCP tools, and real browser automation surfaces for Paseo desktop browser tabs.
* Fix browser tools CI expectations
* Address browser tools review findings
* Restrict browser file automation paths
* Fix browser upload test on Windows
* Harden browser navigation inputs
* Make browser tools create usable tabs
* Update browser MCP empty-state test
* Fail browser tab creation when registration times out
* Fix browser screenshots for agents
* Hide disabled browser tools from agents
* Address browser tools architecture review
* Replace browser tools review tests
* Wrap browser tab registration errors
* Mock Expo Router in app unit tests
* Handle invalid browser automation requests
* Return browser failure on desktop disconnect
* Update browser disconnect websocket test
* Relax browser timeout polling test
* Handle invalid browser responses
* Return browser failure when send fails
* Remove local diagnostics and fixture paths
* Fix dev service home fallback
* Use worktree home for dev services
* Use managed daemon in desktop dev
* fix(browser): keep agent tabs addressable
Track agent-active browser targets separately from human-focused tabs and keep resident webviews alive for automation. Browser tool visibility now comes from registration while the broker reports disabled execution.
* refactor(browser): register tools through catalog
Move browser tool registration onto the shared Paseo tool catalog so the MCP server remains only the transport adapter.
* fix(settings): translate browser tools host error
* ci(playwright): shard app e2e in CI
Run the app Playwright suite across isolated CI shards and keep restarted E2E daemons on the same speech-disabled setup path as global setup.
* test(app): share disabled speech e2e env
* fix(app): place turn footers after trailing tools
Completed turn footers now render on the last visible item before the next user message while still using the latest assistant message for footer content and timing.
* test(app): cover split turn footer placement
* feat(voice): configure OpenAI STT and TTS endpoints separately
Replace providers.openai.voice (a single apiKey/baseUrl shared by
speech-to-text and text-to-speech) with independent providers.openai.stt
and providers.openai.tts, each carrying its own apiKey/baseUrl. STT and
TTS now resolve fully independently, so they can point at different
OpenAI-compatible endpoints. The env equivalents OPENAI_VOICE_API_KEY /
OPENAI_VOICE_BASE_URL split into OPENAI_STT_* and OPENAI_TTS_*.
No backcompat: the voice key is removed and no longer read. Each feature
still falls back to providers.openai.apiKey/baseUrl, then OPENAI_API_KEY/
OPENAI_BASE_URL. Composer dictation resolves from the STT endpoint.
* fix(voice): keep daemon bootable and respect global OpenAI key
Two issues from review of the STT/TTS endpoint split:
- A config from an older release that still sets providers.openai.voice
crashed daemon startup, because the strict schema rejects the now-unknown
key. Strip it before parsing (alongside the existing local.autoDownload
strip) so the daemon boots; the value is discarded, not migrated.
- An empty endpoint env var (e.g. a copied .env.example leaving
OPENAI_STT_API_KEY= blank) shadowed the OPENAI_API_KEY fallback, so
speech was reported as missing credentials despite a configured global
key. firstDefined now skips empty/whitespace strings.
* fix(voice): isolate STT and TTS option parsing per endpoint
An STT-only OpenAI setup could be broken by a stale or invalid TTS env
var (e.g. a leftover TTS_VOICE/TTS_MODEL), because the single resolution
schema validated both endpoints' option groups before the per-endpoint
gate. Split into endpoint-key, STT-option, and TTS-option schemas and
parse each option group only when that endpoint has credentials, so an
unused endpoint's bad env can no longer take down the configured one.
* fix(voice): tag voice-config shim and update direct-daemon test callers
- Mark the providers.openai.voice strip with a COMPAT(openaiVoiceConfig)
comment + removal date so it shows up in the back-compat cleanup
inventory, per repo convention.
- Update the tests that build the daemon directly with a resolved OpenAI
config (bootstrap smoke + the real-API voice/daemon e2e suites) to the
new { stt, tts } shape; the old top-level { apiKey } is no longer read,
and these files are excluded from typecheck so the break was silent.
* fix(voice): update voice-roundtrip debug script to new OpenAI config shape
Last direct daemon caller still passing the removed top-level
openai: { apiKey }; the debug script lives outside tsconfig.scripts.json
so the stale shape wasn't caught by typecheck. Use { stt, tts }.
* feat(app): show host in command center and multi-select host filter
Command-center agent results now show which host the workspace lives on,
shown only when more than one host is connected (matching the sidebar).
The sidebar display-preferences filter becomes multi-select — pick any
number of hosts instead of one-or-all — and its host rows now carry a
status dot on the left like the other host pickers. Persisted filter
state migrates from the old single host to a host list.
* test(app): extract host-filter and command-center e2e DSL helpers
Move the mechanical menu/search interactions out of the new spec bodies
into focused helpers so the tests read as user intent.
* fix(app): drop the icon from the host filter "All hosts" item
* fix(app): make multi-host e2e seeding order-independent
The offline-host seed relied on Playwright running the test's init script
after the fixture's registry reset — an ordering Playwright does not
guarantee. Write the full registry and set the fixture's disable-once flag,
then reload, so the multi-host seed survives regardless of script order.
Also tag the pre-v2 hostFilter migration reader with COMPAT so the cleanup
sweep can find it.
* fix(server): surface Kiro CLI slash commands and skills over ACP
Kiro CLI advertises its slash commands and skills through the
`_kiro.dev/commands/available` ACP extension notification rather than the
standard `available_commands_update` session update. ACPAgentSession only
trace-logged extension notifications and dropped the payload, so the
composer never showed any Kiro commands or skills.
- Map the extension payload onto the shared slash-command cache:
`commands` (built-in, names arrive with a leading "/") and `prompts`
(skills/prompts, tagged with a `skill:` serverName) are normalized to
Paseo's AgentSlashCommand shape, stripping the leading slash and pulling
the argument hint from `meta.hint`.
- Add KiroACPAgentClient (mirrors CursorACPAgentClient) so the provider
waits for the first async command batch before listCommands() resolves,
since Kiro emits the notification shortly after session/new.
* fix(server): settle on empty Kiro batch; inject Kiro commands via constructor option
Addresses review feedback on #1792.
Keep vendor-specific providers out of the generic acp-agent.ts, following
the same pattern as Cursor/Copilot: provider behavior is injected through
ACPAgentClient constructor options rather than special-cased in the base
class. Adds an optional extensionCommandsParser option on the generic ACP
client/session; the base extNotification() invokes it and routes any parsed
commands through a generic applyResolvedCommands() helper. Kiro supplies
parseKiroExtensionCommands (recognizes _kiro.dev/commands/available); the
base class no longer references any Kiro method string or payload shape.
Also fixes an empty-batch hang: applyResolvedCommands() always settles the
initial-commands gate, so a Kiro user with no slash commands no longer
blocks listCommands() for the full 10s timeout. Adds a regression test.
* fix(desktop): report found app updates during manual checks
Manual checks could reuse cached update state while the renderer discarded pending update details. Keep found update versions visible while downloads prepare, and reserve install affordances for ready updates.
* test(desktop): cover manual update retry after errors
* fix(desktop): keep ready updates ready on recheck
The old marker only ever tagged the local host as "Local" based on its
server id. Each row now shows the connection actually in use, inline
after the host name — the TCP or relay endpoint (default :443/:80
stripped), or "Local" for socket/pipe transports.
* fix(server): keep agent fetch RPCs resilient
Agent directory projection assumed every workspace still had a parent project. A stale workspace/project record could reject the whole fetch_agents RPC, so missing placement now behaves like no placement and direct fetches can still return the agent.
* test(server): cover stale agent history fetches
* fix(images): preserve Windows preview paths
Provider image markdown used escaped backslash paths on Windows, which markdown parsing could turn into encoded drive paths that the app treated as workspace-relative. Emit drive-letter paths as file URIs and normalize existing encoded local paths before file preview.
* test(server): accept file URI image sources
Windows provider image output now renders drive-letter paths as file URIs. Decode those markdown sources before filesystem assertions so the image rendering tests keep checking the materialized file.
* fix(images): keep plain local paths literal
Limit legacy URI decoding to markdown-encoded Windows drive paths so ordinary local paths with percent sequences remain unchanged. Document why provider image markdown matching still accepts old doubled-backslash history.
* fix(desktop): show app update check feedback
Manual desktop app update checks now leave visible status feedback even when the shared update state is pending or available. Updater check and preparation errors are carried through the existing result path so the settings row and callout can show the failure instead of only logging it.
* fix(desktop): make update retries perform fresh checks
Manual retries now clear runtime errors emitted by a failed check so the next click calls the updater again. Background checks also skip while a visible manual check is active, and last-checked update copy uses complete localized strings.
* fix(desktop): punctuate update check timestamp copy
* fix(desktop): share runtime update errors
* fix(desktop): preserve update preparation errors
* fix(desktop): settle update check review races
* fix(desktop): settle quiet update check errors
* fix(desktop): handle overlapping update checks
* fix(desktop): preserve preparation errors during checks
* Fix macOS CLI daemon relaunch path
* test(cli): mock helper existence in daemon launch test
* fix(desktop): launch packaged CLI through Helper
The packaged macOS CLI shim entered through the main app executable, so daemon supervision inherited app lifecycle behavior and surfaced Dock icons. Make Helper the required macOS CLI runtime, keep daemon relaunches on process.execPath, and cover cold bundled CLI daemon starts in release smoke.
---------
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
* fix(app): keep New Workspace on the active project
New Workspace preselection was falling back to stale remembered host state when multiple hosts existed. Pass durable active-project context from workspace entry points and centralize initial host selection so stale offline hosts cannot steal the form while startup restore remains last-workspace only.
* fix(app): address New Workspace review feedback
* fix(app): stabilize New Workspace host selection
* fix(app): preserve New Workspace host fallback
* test(app): loosen stale-host project assertion
* fix(app): prefer reachable new workspace hosts
* fix(app): avoid stale offline workspace preselect
* fix(app): prefer online project hosts
* fix(worktree): track checkout PR branches correctly
Checkout-PR worktrees could point branch tracking at a PR push remote without fetching a matching remote-tracking ref. Configure same-repo PRs to track origin, make fork PR remotes fetchable before assigning upstream, and push fork PR branches to their configured remote.
* fix(worktree): scope PR push routing to Paseo targets
Review caught that using ordinary upstream config for Push could send normal branches to non-origin remotes and mishandle deduped PR branch names. Store a Paseo-specific push target during PR worktree creation, then have Push honor only that marker before falling back to origin.
* fix(worktree): use git upstream for PR pushes
* fix(worktree): allow PR checkout without head upstream
* fix(worktree): preserve fork PR git push target
* fix(worktree): preserve PR push targets
* fix(worktree): preserve PR push status
* fix(worktree): set upstream after push-only PR push
* fix(claude): keep subagent text out of parent transcript
Sidechain tool activity is folded into the parent Task/Agent row, but sidechain assistant text was still going through the top-level text assembler. That made the chat show subagent narration without its matching tool rows.
* test(claude): simplify subagent sidechain assertion
* fix(app): preserve assistant stream order
Assistant head flushing only considered item kind, so a new identified assistant message could promote ahead of earlier live rows. Flush the live head when assistant message ids diverge so live rows remain an ordered suffix.
* refactor(app): reuse assistant message id helper
* fix(daemon): log stop reasons and client identity
Record websocket client identity, process memory/uptime, and shutdown reasons across CLI, desktop, supervisor, and worker paths so daemon drops can be traced from the triggering client to worker termination.
* fix(daemon): keep shutdown diagnostics in sync
Test drift: supervisor and relay tests still asserted the old log text and metadata shape after shutdown diagnostics started logging structured reasons and relay connection ids. Update those assertions to the new diagnostic contract.
Also centralize client lifecycle reason normalization and derive desktop daemon stop reasons from one tuple so future changes cannot silently drift.
* fix(client): wait longer for session responses
* fix(client): default RPC waits to sixty seconds
* fix(app): preserve detached stream scroll on delayed history
Code drift: longer client RPC waits let delayed timeline responses arrive after a user scroll-away, so web stream anchoring must not reattach on transient scroll-top resets.
Restore the 15s connect deadline and leave app initialization slack above the default 60s session RPC wait.
* fix(client): keep helper waits within caller deadlines
Review fix: the 60s default session RPC wait leaked into wait previews and waitForAgentUpsert helper fetches. Bound those helper RPCs to the caller deadline or a short best-effort preview timeout, and allow small scroll ranges to reattach at bottom.
* fix(client): respect caller timeout budgets
* fix(cli): keep diagnostic probes responsive
* Refactor daemon client request options
* Preserve daemon client legacy overloads
Decouples file drag-and-drop into a FileDropZone provider (drag target + backdrop) and a useFileDrop consumer hook, with drag state on a Reanimated shared value so dragging triggers no React re-renders.
Fixes the collapsed/invisible backdrop (the drop zone had been moved into the composer with flex:0, collapsing the overlay to zero height) and restores whole-surface coverage across the agent panel, New Workspace screen, draft workspace tab, and workspace-setup dialog. The backdrop and copy cursor only appear when a drop will actually be accepted (a consumer is mounted and not submitting).
Refs #520
The host picker reused the composer trigger's content-width pill, so its
label sat 6px left of the section rows and the chevron hugged the label
instead of the trailing edge. Add a `block` mode to ComboboxTrigger that
fills the width and uses the sidebar-row gap, and size the status dot to
the icon footprint so the label and chevron line up with the rows below.
* fix(app): keep slash commands visible after New Workspace
Repeated returns from the app-wide New Workspace route were using dismissTo with the workspace leaf URL. That updated the root URL without popping the nested host stack, so hidden duplicate workspace deck entries could remain mounted and steal composer popover measurements.
Dispatch a root-stack POP_TO into the host workspace route instead, with a Playwright regression for the slash-command popover.
* test(app): tighten workspace autocomplete regression
Derive the expected deck count from the seeded workspaces and make the duplicate-deck assertion hard. Keep the Expo Router pop hint documented because removing it reproduces the hidden deck entries.
The host name previously appeared only in project grouping mode, and only
for projects shared across hosts. It now shows on every workspace row in
both project and status grouping modes whenever the sidebar spans more than
one host, so you can always tell which machine a workspace lives on. Status
rows read "Project · Host". Single-host setups are unchanged.
* fix(app): show add project search loading state
The Add Project picker now treats a typed value that is ahead of the debounced directory search as an active search, so empty results show a loading message instead of the initial prompt or a blank list.
* fix(app): avoid overlapping project picker states
* fix(acp): add tests asserting cwd and mcpServers are always passed to session/load (#1593)
Add unit tests in acp-agent.test.ts that verify initializeResumedSession()
always calls loadSession and unstable_resumeSession with { sessionId, cwd,
mcpServers } — even when mcpServers is an empty array. Some strict ACP
providers (e.g., Devin CLI) return 'Invalid params' if any of these fields
are omitted.
Also adds a docstring above initializeResumedSession() documenting this
requirement so future refactors don't accidentally drop params.
Closes#1593
* fix(acp): address Greptile review on session/load invariant tests
- Extract shared makeTestSession() factory to eliminate duplicated TestSession
class definitions across all three tests (concern #1)
- Pass handle through typed constructor option instead of casting private
initialHandle field (concern #2)
- Add missing type imports for AgentCapabilityFlags and AgentPersistenceHandle
* fix(tests): ensure ACP agent session load invariant
* feat(pi): make extension result timeout configurable via provider params
* fix(acp): add tests asserting cwd and mcpServers are always passed to session/load (#1593)
Add unit tests in acp-agent.test.ts that verify initializeResumedSession()
always calls loadSession and unstable_resumeSession with { sessionId, cwd,
mcpServers } — even when mcpServers is an empty array. Some strict ACP
providers (e.g., Devin CLI) return 'Invalid params' if any of these fields
are omitted.
Also adds a docstring above initializeResumedSession() documenting this
requirement so future refactors don't accidentally drop params.
Closes#1593
* fix(acp): address Greptile review on session/load invariant tests
- Extract shared makeTestSession() factory to eliminate duplicated TestSession
class definitions across all three tests (concern #1)
- Pass handle through typed constructor option instead of casting private
initialHandle field (concern #2)
- Add missing type imports for AgentCapabilityFlags and AgentPersistenceHandle
* fix(tests): ensure ACP agent session load invariant
* fix(pi): increase default extension result timeout to 30s
* fix(app): attach dropped files in composer
Composer parents had to expose attachment callbacks, so New Workspace only wired image drops and JSON files never became file attachments. Centralize dropped-file ingestion in Composer so all composer surfaces share the same image and file path.
* fix(app): handle desktop dropped attachment paths
* fix(server): use terminateWithTreeKill in Claude Code provider close()
The Claude Code agent's close() method only called SDK-level cleanup
(query.close/interrupt/return) which may only kill the direct child
process. MCP server processes spawned by the Claude CLI survived as
orphans, accumulating over time and consuming significant memory.
Fix:
- Add onChildProcess callback to ClaudeQueryContext to capture the
spawned child process reference
- Store the child process in ClaudeAgentSession
- In close(), call terminateWithTreeKill() after SDK cleanup to
recursively terminate the entire process tree (claude + MCP children)
- Use 2s graceful (SIGTERM) / 2s force (SIGKILL) timeouts, consistent
with other providers (ACP, Codex, Pi, OpenCode)
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(server): tree-kill old process during query restart to prevent MCP orphans
Greptile review identified a gap: ensureQuery() replaces this.childProcess
when spawning a new query without first killing the old process tree.
During reconnection events, this can leak MCP children from the
previous claude process.
Fix: add terminateWithTreeKill() in the queryRestartNeeded block
after SDK cleanup, before the replacement query spawns.
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
* fix(opencode): prevent indexing the entire home directory
Paseo launches opencode serve with cwd=os.homedir() and refreshes the
global provider snapshot with directory=/Users/admin. OpenCode treats
that as a workspace and starts location services + bigram indexing for
the entire home tree, causing ~466% CPU and ~4GB RAM usage.
- Use a neutral scratch directory as the opencode serve cwd.
- Use a separate scratch directory for global provider catalog refresh
so model/mode discovery no longer triggers home directory indexing.
Fixes high CPU/RAM when Paseo starts opencode with no explicit project.
* fix(opencode): use realpath-aware matcher for home detection in catalog refresh
Switch the home-directory check in fetchCatalog from a string-based
path.resolve() comparison to createRealpathAwarePathMatcher, so we
catch macOS /private/var/... aliases, symlinks, trailing separators,
and Windows casing — consistent with the rest of opencode-agent.ts.
Also:
- Hoist the matcher to module scope so each fetchCatalog call doesn't
rebuild it (the matcher runs realpathSync twice on construction).
- Log a debug line when we rewrite the cwd to the scratch path, so
it's easy to diagnose missing per-directory config in catalog scope.
- Update opencode-agent.test.ts to expect the scratch directory when
cwd === os.homedir(), with a comment pointing to the rationale.
* fix(opencode): isolate helper server home
* fix(opencode): pass semantic global catalog scope
* fix(opencode): release catalog acquisition on home resolution failure
---------
Co-authored-by: rex-chang <rex-chang@users.noreply.github.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
* fix(app): eliminate terminal scroll lag by removing MutationObserver and throttling scroll events
The MutationObserver with subtree:true was firing React setState on every
xterm DOM mutation (character output, cursor blink, scroll line rendering),
causing cascading React re-renders that blocked the main thread and made
the mouse wheel unresponsive on web.
Changes:
- Remove MutationObserver (redundant with ResizeObserver for viewport sizing)
- Throttle scroll handler with requestAnimationFrame to batch setState calls
- Early-return in updateViewportMetrics when metrics unchanged since last emit
- Cancel pending rAF on cleanup to avoid stale updates
Co-Authored-By: TommyLike <tommylikehu@gmail.com>
* Fix terminal scrollbar metric dedupe state
---------
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
Long starting refs could paint past capped badges because the shared combobox trigger row kept its intrinsic width. Make the trigger row shrink-safe and clip the New Workspace badge contents.
Single-host users should not see host-selection chrome, while multi-host surfaces keep shared combobox feedback and consistent hover-card metadata rows.
Wire the @replit/codemirror-lang-csharp Lezer grammar into @getpaseo/highlight following the existing per-language pattern (Java/Rust/Swift). Map the cs extension to the parser and alias the csharp and c# markdown fence names so fenced code blocks highlight.
Register csharpLanguage.parser rather than the package's raw parser export: the highlight styleTags are applied only inside csharpLanguage (via parser.configure), so the raw parser would parse but render unstyled.
Closes#1527
Co-authored-by: Clemens Wagner <wagner.clemens@gmx.de>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
openProject 10s->60s and getDirectorySuggestions 10s->30s + debounce the
project-picker query 250ms. On large local repos the daemon's path-resolve and
home-tree scan take several seconds; firing per-keystroke against a 10s timeout
raced the suggestion list to empty (e.g. ~/gi found a hit but ~/gith blanked)
and surfaced a spurious 'Timeout waiting for message (10000ms)' on add.
Co-authored-by: jms830 <jms830@noreply.github.com>
File previews read once and then froze: visited tabs stay mounted-but-hidden
(LRU in useMountedTabSet), so refetchOnMount never re-fired and the only way
to see fresh content was to close and reopen the tab.
Gate the file query's `enabled` on tab + app visibility so React Query
refetches on the disabled->enabled transition — and only when the cached read
is stale (keeping the existing 5s staleTime), so a quick flip away and back
doesn't re-read. Covers switching between tabs and backgrounding/reopening the
whole app to the same tab.
Adds a wiring test covering the stale-revisit refetch, the fresh-revisit
debounce, the app-foreground refetch, and the read-error path.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* fix(server): no-op agent hooks when PASEO_TERMINAL_ID is unset
The shell command generated by `buildAgentHookShellCommand` short-circuited
on `[ -n "$PASEO_TERMINAL_ID" ] && ...` and exited 1 when the terminal
ID was absent, so Claude Code reported every Notification/Stop/StopFailure/
SessionEnd/UserPromptSubmit event as `Failed with non-blocking status code`.
The same shape affected the Codex provider.
Switch the gate to `if [ -n ... ]; then ...; fi` so the hook is a true
no-op when the terminal id is unset and preserves the underlying CLI's
exit code when it actually runs. Mirrors `opencode-plugin.ts`, which
already early-returns on the same env var. Adds the matching
`else (exit /b 0)` branch to `buildAgentHookWindowsCommand`.
Updates the literal-string assertions in the existing Claude and Codex
hook tests, and adds an executable regression that runs the generated
shell command through `/bin/sh` and asserts exit 0 across all five
Claude hook events.
* test(server): skip POSIX hook regression on Windows
The /bin/sh exit-code regression spawns /bin/sh -c <posix command>,
which has no equivalent on the Windows runner, so spawnSync returned
status: null and the five Claude events failed in CI.
Gate the it.each block with skipIf(isPlatform("win32")), matching the
pattern already used in worker-terminal-manager.test.ts and
executable-resolution.test.ts. The remaining literal-string and
buildTerminalEnvironment assertions in this file keep running on
every platform.
* feat: add remote daemon self-update from client
Adds a daemon.update.request RPC that lets the client trigger a remote
daemon update over WebSocket. The daemon runs npm update -g @getpaseo/cli,
reports progress via daemon_update_progress status events, then restarts
through the supervisor to load the new code.
New feature flag server_info.features.daemonSelfUpdate gates the UI.
The UpdateDaemonCard appears on the host settings page when the daemon
supports self-update and the app/daemon versions differ.
* fix: clean up progress subscription on unmount, use typed phase cast
Address Greptile review:
- P1: store unsubscribe ref and clean up on component unmount to prevent
listener leak during in-flight update
- P2: use DaemonUpdateProgressStatusPayload type instead of { phase?: string }
* fix: use npm install@latest and global concurrency guard
Address Greptile review:
- npm update -g can silently exit 0 without resolving major-version
gaps; switch to npm install -g @getpaseo/cli@latest
- Move daemonUpdateInProgress from per-session instance to module-level
variable so concurrent clients share the same guard
* feat(daemon): harden remote self-update
Probe the global npm CLI install before updating, keep request handling in a daemon self-update session controller, and emit scoped daemon.update.progress messages.
* test(daemon): make self-update paths platform-stable
---------
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
* fix(app): make new workspace global
New Workspace now opens as an app-wide screen. Host and project context can still preselect the form through query params, but the route itself no longer lives under a host.
* fix(app): simplify new workspace selection
* Add Docker images and agent Docker Mods
Ship official container images that run the Paseo daemon headless. One
Dockerfile parametrized by BASE_IMAGE covers Debian 12/13, Ubuntu 22.04/24.04
and Alpine; it bundles Node 22, the npm-published server + CLI, a vendored
s6-overlay as PID 1, and a small Docker Mods loader.
Agents are chosen at runtime via DOCKER_MODS (pipe-separated mod images). Each
mod is a FROM scratch image carrying only an install hook that runs
`npm install -g <agent-cli>`; the loader pulls the layers from the registry,
extracts them, and runs the hook before the daemon starts, so any requested
agent is on PATH when Paseo probes provider availability.
- docker/base: Dockerfile, install scripts, s6 services, mods loader
- docker/mods/*: claude-code, codex, copilot, opencode, pi
- docker/docker-compose.example.yml + docker/README.md
- .github/workflows/docker.yml: multi-arch (amd64/arm64) buildx matrix,
publishes to ghcr.io/getpaseo on version tags
- docs/docker.md + CLAUDE.md docs index row
* feat(docker): print pairing QR and link on daemon startup
Add an s6 oneshot service that waits for the daemon to listen, then runs
`paseo daemon pair` so the pairing QR code and link surface in the container
logs. Best-effort: never blocks boot, skips gracefully when relay is disabled.
Opt out with PASEO_PAIRING_QR=0.
* build(docker): add Arch image support
* ci(docker): build Arch without Buildx
* docs(docker): document paseo env contract
* feat(docker): add opt-in sudo mode
* docs(docker): link env references
* fix(docker): create agent config dirs
* fix(docker): default home to /home/paseo
* docs(docker): document agent auth setup
* docs(docker): document relay port setup
* fix(docker): install Node from tarball
* docs: add Docker quick start
* docs(docker): remove legacy home example
* docs(docker): set container hostname
* fix(docker): prepare opencode storage
* fix(docker): allow paseo login shell
* fix docker opencode permissions
* ci(docker): use Node 24 actions
* fix(docker): install bzip2 runtime tools
* fix(docker): update Pi mod package
* fix(docker): quiet default daemon logs
* fix(docker): split home from state
Docker images now keep HOME at /home/paseo and store Paseo daemon state under /home/paseo/.paseo by default.
Existing volumes can keep the old layout by setting PASEO_HOME=/home/paseo.
* fix(docker): keep mods out of paseo home
* ci(docker): skip alpine arm64 builds
* fix(docker): address review findings
* fix(docker): verify s6 overlay downloads
* fix(docker): honor custom healthcheck port
* fix(docker): fail on mod extraction errors
* feat(docker): add official container image
Ship a focused daemon image with the bundled web UI enabled and document extending it with agent CLIs.
* ci(docker): publish images only on stable releases
* fix(docker): check daemon health over HTTP
---------
Co-authored-by: Herbrant <cdavide98carnemolla@gmail.com>
* fix(attachments): allow Markdown file uploads on desktop
Desktop file upload copied picked files through managed storage with bare picker extensions like md. Normalize those extensions at the command boundary and pass dot-prefixed extensions from the picker path.
* fix(attachments): stop enumerating generic file types
Generic file uploads should not depend on a hand-maintained non-image MIME table. Keep raster image inference for image handling and use octet-stream for other path-only uploads.
* test(attachments): cover picker extension format
* feat(server): add MiniMax quota fetcher
Mirror the live multi-provider quota panel (#1278) for MiniMax. Resolves the
bearer token from $MINIMAX_API_KEY or the MiniMax CLI config at
~/.mmx/config.json (api_key, oauth.access_token) and credentials at
~/.mmx/credentials.json, then queries the /v1/token_plan/remains endpoint on
the configured region (global by default, cn when region='cn'). The response
is normalized into ProviderUsage windows per model_remains entry, surfacing
the interval and weekly limits with their reset times and a danger tone when
the server reports status=2.
* feat(app): add MiniMax brand icon for usage page
The quota panel resolves the icon via resolveProviderIconName, which falls
back to a generic Bot for any provider not in BUILTIN_PROVIDER_ICON_NAMES.
Register 'minimax' as a built-in icon and vendor the official 24×24 brand
mark (single Path, currentColor) as MiniMaxIcon so the usage card stops
showing the generic robot.
* refactor(server): flatten nested ternaries in MiniMax quota fetcher
Greptile review on #1662 called out two nested ternaries in toIntervalWindow
and toWeeklyWindow. Extract a toneForStatus helper that uses an explicit
if/else chain so the same status-to-tone mapping is shared between the
interval and weekly windows, and add the missing trailing newline.
* fix: format
Documents the daemon-served web UI from #1635: enabling it, reverse
proxy, TLS, tunnels, and the auth/exposure model. Adds a community
projects page for community-built self-hosting tooling.
* Merge workspaces across all hosts in the sidebar
Replace per-host sidebar sections with a single merged project list.
Projects that exist on multiple hosts (matched by projectKey) are
collapsed into one row. Host identity is surfaced via:
- A status-only footer pill showing online/offline host counts
- Hover cards with a mandatory host row on every workspace entry
- Subtitles showing the host name when a project spans >1 host
- A host filter in the grouping dropdown (not a sidebar switcher)
Order and group-mode stores migrated from per-server to global keys
with zustand persist migrations. New-workspace screen now shows a
merged project list with a host selector combobox.
Key files:
- workspace-structure.ts: merge algorithm by projectKey
- sidebar-view-store.ts / sidebar-order-store.ts: global state
- sidebar-workspace-list.tsx: context-based subtitle injection
- new-workspace-screen.tsx: host selector in footer row
* Add host filtering to the merged sidebar
The sidebar now merges workspaces from every connected host, so a single
global "active host" no longer fits. Host selection becomes explicit per
action via a host chooser, and agent history pages across all hosts at
once. Removes the active-host / active-server-id model and the
all-agents-list hook that fed it.
* Fix rebase fallout: translate archive host error, update stale workspace-key test
archiveWorkspacesOptimistically used a raw "Host is not connected" string
that the i18n completeness test forbids; route it through i18n.t with the
existing hostDisconnected key. The selectors memoization test asserted
un-prefixed workspace keys, but merged-sidebar keys are serverId-prefixed.
* Fix sidebar review fallout
* Address sidebar review followups
* Fix sidebar workspace identity migration
* Address sidebar review followups
* Handle partial host history failures
* Fix merged sidebar project removal and E2E drift
* Restore history routes and project removal shape
* Remove sidebar host context plumbing
* Tighten sidebar status and host filter state
* Keep new workspace project selection host-compatible
* Migrate sidebar view storage
* Streamline host selection flows
* Update all-host history on archive
* Fix empty project sidebar action
Code regression: the main merge dropped the empty-project New workspace child row while keeping the empty project persistence contract. Restore the row using the project host target so the sidebar stays aligned with the no-host-selection model.
* Fix Japanese shortcut translation key
Stale-base integration: main added the Japanese locale after this branch added cycleAgentMode to the English shortcut help map. Merging main exposed the missing ja key in CI typecheck and resources.test.
* Fix Playwright speech teardown race
* Serve the web client from the daemon
Keep the bundled browser UI opt-in and exclude it from desktop packaging so desktop builds do not ship a duplicate renderer.
* Escape daemon web UI bootstrap hint
* Fix bundled web UI dist path
Diagnostic sheets now share one themed code surface. String reports render line by line so large log lines do not turn the whole report into one oversized native text surface.
Resolve OpenAI voice credentials and endpoints from voice-specific config before broader OpenAI fallbacks, and use the same REST STT provider for dictation and voice mode.
Provider settings sheets can remain mounted while hidden, so the old display cache could survive into a different provider. Scope the cache to the active provider identity and remount the sheet when that identity changes.
* refactor(agent): decouple Paseo tools from MCP
Keep MCP as the fallback adapter while exposing the same runtime catalog for providers that can register tools directly.
* fix(protocol): keep native tool support server-internal
* fix(i18n): sync Japanese shortcut labels
* fix(agent): preserve schedule provider error
* fix(server): link PRs for differently named tracked branches
PR lookup now uses the configured tracked head ref when it differs from the local branch, while keeping fork owner scoping only for cross-repo remotes.
* fix(server): avoid extra PR lookup git reads
Use the shared lookup-target builder for an early local-branch result before reading origin and base metadata on on-demand PR status refreshes.
* refactor(server): extract the workspace-scripts feature into a deep module
The service-proxy-backed "workspace scripts" feature had no home: building a
workspace's scripts payload was duplicated between the descriptor builder and
buildWorkspaceScriptPayloadSnapshot (the same 9-input buildWorkspaceScriptPayloads
assembly), and the "scripts available on this daemon?" guard appeared a third time
in the start handler.
Move the feature into session/workspace-scripts/workspace-scripts-service.ts behind
createWorkspaceScriptsService(deps): { buildSnapshot, emitStatusUpdate, start }. The
payload assembly and availability guard now live in one place; the descriptor builder,
the status-emission path, and the start RPC all funnel through it. spawnWorkspaceScript
is injected as a port so the feature is testable without a real process. session.ts
drops ~98 lines; the two methods existing tests reach stay as thin delegators.
New zero-mock unit test covers the guard, status emission, and the start branch matrix
with injected fakes, the real service proxy + runtime store, and a fake launcher.
* refactor(server): address review — durable comment + documented test stand-in
- Drop the transient "#1714" PR reference from the buildWorkspaceScriptPayloadSnapshot
accessor comment; state the durable reason instead.
- Hoist the opaque terminalManager test stand-in to a named const documenting the
non-call guarantee, rather than an inline `as unknown as` cast.
Move the per-client `agent_update` subscription out of session.ts into
session/agent-updates/ behind createAgentUpdatesService(deps). The module owns
the mutable subscription state, the bootstrap buffer, the provider-visibility
gate, and the filter predicate; the rest of session.ts no longer pokes the
subscription shape or hand-rolls `agent_update` payloads — the ~12 call sites
collapse to thin delegations (forwardLiveAgent / emitStoredRecord / removeAgent /
beginSubscription / flushBootstrapped / clearSubscription / dispose) plus the
pure, shared matchesAgentUpdatesFilter used by the snapshot listing pager.
The shared payload builders (buildAgentPayload / buildStoredAgentPayload /
isProviderVisibleToClient) and buildProjectPlacementForWorkspaceId stay in
session.ts and are injected as host callbacks, since they are used widely
outside this cluster. session.ts: 6187 -> 5967.
Behavior preserved: the existing session.workspaces.test.ts integration suite is
repointed through the new boundary and stays green; the previously-untested
filter / buffer / flush branches are now covered by a zero-mock unit test with
injected fakes. cleanup() now disposes the subscription (it was never cleared
before). The clear-attention-for-workspace path that emits agent_update directly
(bypassing the buffer) is left as-is — a pre-existing inconsistency, out of scope.
* refactor(server): extract workspace git-observer into a deep module
Move the workspace git-observer cluster out of session.ts into
session/workspace-git-observer/ behind createWorkspaceGitObserverService(deps).
The module owns the per-cwd watch targets and the WorkspaceGitService
subscription handles, so the registration / dedupe / branch-change / teardown
lifecycle lives in one place instead of being reached into by hand from the
emit hot-path, the archive path, CheckoutSession's host, and dispose.
session.ts: 6401 -> 6219 lines. The caller surface narrows: archive teardown
calls removeForWorkspaceId / removeForCwd (no longer resolving watch targets and
cwds itself), and dispose() replaces iterating a raw subscription Map.
Sheds dead code orphaned by 40e27f5a0 (FSWatcher watching moved into
WorkspaceGitService): the workspaceGitFetchSubscriptions Map (never written), the
WorkspaceGitWatchTarget watchers/debounceTimer/refreshPromise/refreshQueued
fields, and the no-op closeWorkspaceGitWatchTarget method.
The module is unit-tested with injected fakes (zero mocks); the existing
session.workspace-git-watch.test.ts integration suite is repointed through the
new boundary and still passes.
Behavior is preserved verbatim, including a latent quirk where syncObservers
seeds descriptor state with a directory in the workspaceId slot (an effective
no-op) -- flagged as a follow-up, not changed here.
* refactor(server): clear watch targets on dispose; document shouldSkipUpdate dedupe
Addresses Greptile review on PR #1714:
- dispose() now clears watchTargets alongside subscriptions so post-teardown
state is consistent (dispose is terminal; behavior-preserving).
- Document shouldSkipUpdate as a check-and-record dedupe gate (mutation on the
changed path is intentional, preserved from the original).
* test(server): make git-observer test paths platform-portable
server-tests (windows-latest) failed: the test asserted raw POSIX path literals
against the service's resolve(cwd)-normalized output, which becomes D:\repo\ws1
on Windows. Resolve the test cwds the same way so assertions hold on every
platform (matches the path.resolve pattern in session.workspace-git-watch.test).
Test-only fix; production normalization is unchanged and correct.
* feat(providers): render images Claude Code emits instead of base64
Claude returns images as base64 blocks inside tool_result content; they were
dumped into the tool output as a wall of raw base64 text. Materialize them to a
temp file and emit an assistant_message markdown image — the path Codex already
uses — so the client fetches the bytes over the existing WebSocket binary-frame
channel. No protocol or client change; assistant_message is an existing type.
Also strips the base64 out of the failed-tool-call error field, and widens the
Claude history-replay filter so the image survives reload. Collapses the image
writer Codex had duplicated twice into one shared materializeProviderImage.
* fix(providers): match only materialized image paths in the history filter
The history-replay filter recognized a provider image by a leading "![". Review
flagged that user-authored text starting with an image-markdown pointing at the
attachments dir could then replay as assistant output. Tighten the predicate to
the content-hashed <sha256>.<ext> shape the writer actually emits, so only
materialized images pass.
* fix(providers): handle Windows paths in the image-markdown predicate
On Windows the materialized image path uses backslash separators, and
escapeMarkdownImageSource doubles each backslash in the markdown source. The
history-replay predicate matched a single separator, so on a Windows daemon the
image was dropped on reload; the test path assertions failed for the same
reason. Allow one-or-more separators in the predicate, unescape the source in
the test helper, and add a Windows-path case to the predicate test.
* refactor(server): extract workspace provisioning into workspace-provisioning module
Move the "find-or-create a workspace & project for a directory" cluster out of
the 6.4k-line Session god class into session/workspace-provisioning/
behind an injected createWorkspaceProvisioningService(deps) port.
Nine methods (~215 LOC) — findOrCreateWorkspaceForDirectory,
resolveOrCreateWorkspaceIdForCreateAgent, createWorkspaceForDirectory,
findOrCreateProjectForDirectory, ensureWorkspaceRecordUnarchived, and the
private helpers resolveWorkspaceDirectory / findExactWorkspaceByDirectory /
reclassifyOrUnarchiveWorkspaceForDirectory / resolveProjectRecordForPlacement —
now live in one deep module with a 5-method interface. The session shed four
pure-function imports it no longer needs and delegates from five call sites.
The module's only dependencies are the workspace/project registries and the
git-service checkout port — no emit, clientActivity, or observer coupling — so
it is now unit-testable with real file-backed registries and a fake git port
(workspace-provisioning-service.test.ts), instead of only through a booted
Session. Behavior is unchanged: the existing open_project / import / create-agent
characterization suites stay green.
* refactor(server): address review — drop redundant spread property, test always-create
- Remove the dead `workspaceId: input.workspace.workspaceId` in
reclassifyOrUnarchiveWorkspaceForDirectory (already spread via
...input.workspace; behavior-identical).
- Add a direct test pinning createWorkspaceForDirectory's always-mint-fresh
contract (call twice for the same cwd → two distinct workspace ids), the
property that distinguishes it from findOrCreateWorkspaceForDirectory.
* refactor(server): extract git-mutation primitives into git-mutation module
Move checkoutExistingBranch, createBranchFromBase and notifyGitMutation
(plus their internal ref-validation / clean-tree / branch-existence helpers)
out of the 6.5k-line Session class into
session/git-mutation/git-mutation-service.ts — a deep module with a
3-method interface and a createGitMutationService(deps) factory, mirroring
the git-metadata-generator port extracted in #1702.
These primitives were smeared across three sub-session boundaries as host
callbacks. CheckoutSession now takes gitMutation directly, shrinking
CheckoutSessionHost from 6 members to 4; the worktree session-config
builder and the auto-naming / worktree-creation paths call
this.gitMutation.* instead of private Session methods.
Adds git-mutation-service.test.ts: guard branches covered with in-memory
fakes, happy paths against a real temp git repo. The superseded
internal-mock tests in session.test.ts (which stubbed execCommand and the
git service) are removed in favour of the real-dependency coverage.
* refactor(server): address review on git-mutation-service
- Inline doesLocalBranchExist into its sole caller: once the redundant
ref-validation is removed (createBranchFromBase already validates
newBranchName with the "new branch" label), the helper is a pure
passthrough to workspaceGitService.hasLocalBranch.
- Replace .some(...).toBe(true) snapshot assertions with toContainEqual so
failures surface the full recorded call (and assert cwd too).
The LLM-backed commit-message and PR-text generators lived in the 6.6k-line
session god-file but were consumed only by CheckoutSession, which reached them
through two host callbacks (the host comment even flagged that it "does not own
them"). The two methods were ~80% identical.
Lift them into session/checkout/git-metadata-generator.ts as a deep module with
a two-method interface, collapsing the duplicated diff -> fileList -> patch ->
prompt -> generate -> fallback scaffold. The LLM call is now an injected
StructuredTextGeneration port: production wires resolve-providers + structured
generation; the new unit test injects a fake and exercises success, both
fallback paths, and error rethrow without mocking any module.
CheckoutSession takes the generator as a typed collaborator instead of two host
callbacks; Session sheds ~160 lines plus five now-unused imports. Behaviour is
unchanged — the existing session.test.ts generation tests pass untouched.
* feat(i18n): add Japanese (ja) locale
- Add packages/app/src/i18n/resources/ja.ts with 1287 keys matching en.ts
- Register ja in i18next.ts, locales.ts (SupportedLocale, LANGUAGE_OPTIONS,
SUPPORTED_LANGUAGES, LANGUAGE_NATIVE_NAMES, LANGUAGE_NAMES_BY_LOCALE,
resolveSupportedLocale)
- Add ja label key to all six existing locale files (ar, en, es, fr, ru, zh-CN)
- Update locales.test.ts to cover ja in all relevant assertions
Translation reviewed by Codex across four passes; terminology, placeholder
parity, and naturalness confirmed clean.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(i18n): add ja to resources.test.ts and fix test descriptions
- Import ja and add to key-parity, fallback-ratio, interpolation, and
model-count assertions in resources.test.ts
- Update test descriptions to say "all supported language(s)" instead of
"UN official language(s)" (ja is not a UN official language)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* docs: add Japanese README (README.ja.md)
Add README.ja.md translated from the English source, reviewed by Codex
across two passes. Link it in the language navigation of README.md and
README.zh-CN.md.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix: apply oxfmt formatting to locales.ts and ja.ts
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(claude): stop probing context usage after turns
Use Claude stream/result usage for parent context tracking, and update the meter from compact boundary post tokens. Subagent usage reports stay out of the parent context meter.
* fix(claude): clear compact context fallback between turns
* Fix OMP slash command discovery
Keep Pi on get_commands while allowing the OMP-backed provider to use get_available_commands. Both providers reject the other command without echoing an id, so the adapter needs an explicit command-list RPC setting instead of a timeout-based fallback.
* Address OMP command test review
* Fix OpenCode helper server ownership
Own OpenCode helper generations as soon as spawn returns so startup timeout and shutdown clean up through the manager/reaper path. Collapse the OpenCode runtime wrapper so the provider talks to the server manager directly.
* Protect dedicated OpenCode server startup
Native state restore can resume at the host route instead of the root index, so host home now resolves the remembered workspace itself. Keep shared route policy outside src/app so Expo Router does not register it as a page.
* fix(server): align provider catalog diagnostics
* fix(server): preserve catalog profile models
* refactor: unify provider catalog discovery under AgentClient.fetchCatalog
Remove listModels/listModes from AgentClient and fetchModels/fetchModes
from ProviderDefinition. All provider runtime discovery now flows through
a single fetchCatalog(options) => ProviderCatalog API.
ProviderSnapshotManager.listModels/listModes remain as cached snapshot
conveniences only. Provider implementations (acp, codex, opencode, pi,
claude, mock) updated accordingly; agent-manager default model resolution
now calls fetchCatalog.
Reshape step toward issue pi-model-list-empty.
* refactor: remove remaining provider listModels/listModes runtime API residue
Migrate remaining AgentClient/provider-client implementations and tests to
fetchCatalog. Remove obsolete ListModelsOptions/ListModesOptions interfaces.
Update ProviderSnapshotManager.getProviderDiagnostic to materialize clients
via ensureClient(provider, definition) so diagnostics self-heal the settings
sheet instead of failing when providerClients[provider] is absent.
Allowed to remain: ProviderSnapshotManager.listModels/listModes as cached
snapshot readers; protocol/client legacy list_provider_models names; unrelated
local helper in create-agent-mode.
* test(server): repair test clients after fetchCatalog refactor
- Restore TestAgentClient.fetchCatalog with proper model list and resumeSession.
- Restore NativeArchiveRecordingClient and EnvProbeAgentClient removed during refactor.
- Fix ResumeCaptureClient.fetchCatalog and resumeSession.
- Fix stream-coalescing TestAgentClient.fetchCatalog shape and isAvailable.
- Mock accessible OpenCode provider in full-access mode tests so fetchCatalog does not throw.
* refactor(app): update stable discovered models ref directly during render
* feat(app): add toolbar and loading states to the GitHub PR panel
Adds a toolbar under the PR title with a refresh button (refreshes git
and GitHub status + timeline in one tap), an always-visible View Pull
Request button, and a Merge control with a method dropdown that is
disabled-with-reason when the PR can't be merged yet.
The merge control reuses the existing git-actions policy verbatim via a
new buildPrPanelMergeActions selector fed into GitActionsSplitButton, so
no merge readiness/squash/auto-merge logic is duplicated.
Also replaces the empty-state flash with skeletons: a full-panel
skeleton while PR status loads and an activity skeleton while the
timeline loads, with empty states shown only when loaded-and-empty and
an error state with retry.
* refactor(app): tidy PR panel skeleton (i18n, shared pulse, index)
Addresses review feedback on the PR panel work:
- Localize the skeleton's "Checks" label via t() instead of hardcoding.
- Hoist the shared SkeletonPulse + useSkeletonPulse into activity-skeleton
so pane-skeleton reuses them instead of duplicating the pulse driver.
- Export PullRequestPaneError/Skeleton from the module index and import
them through it, matching the module's existing public surface.
* test(app): remove mock-based PR pane test
* fix(app): simplify PR panel toolbar
* fix(app): report PR panel retry failures
* fix(app): preserve composer mode preferences
Treat undefined provider preference updates as no-ops, and keep saved mode selections instead of silently defaulting them when provider metadata is incomplete or does not list the saved mode.
* fix(app): document mode preference preservation
* fix(app): keep compact explorer mounted while open
* fix(app): share compact explorer host state
* fix(app): refresh diff row metrics after font changes
* refactor(server): move checkout write handlers into CheckoutSession
The checkout read side moved into session/checkout/checkout-session.ts in
#1644; the 17 inline write/PR/stash handlers stayed on the Session god-object.
This carves them into the same subsystem behind an expanded CheckoutSessionHost,
so dispatchCheckoutMessage becomes pure delegation and session.ts drops ~800
lines.
Moved into CheckoutSession (verbatim, only this.X -> this.host.X rewiring):
switch/rename branch, commit, merge, merge-from-base, pull, push, PR
create/merge, github auto-merge/check-details, PR status/timeline, github
search, stash save/pop/list, plus resolveCurrentPullRequest and the
PR-timeline helpers.
CheckoutSessionHost gains the Session-owned, non-checkout collaborators these
handlers orchestrate (notifyGitMutation, emitWorkspaceUpdateForCwd,
handleWorkspaceGitBranchSnapshot, renameCurrentBranch, checkoutExistingBranch,
and the LLM commit/PR-text generators); paseoHome/worktreesRoot join the
options bag. GitMutationRefreshReason moves to utils/checkout-git.ts so both
the shell and the subsystem share one canonical type.
session.test.ts checkout tests now drive the handlers through the public
handleMessage boundary instead of reaching into private methods; new mock-free
fake-host tests cover the moved handlers directly in checkout-session.test.ts.
Behavior-preserving: full session.test.ts + checkout-session.test.ts green,
typecheck/lint/format clean.
* refactor(server): extract chat/schedule/loop handlers into ChatScheduleLoopSession
Move the 7 chat/*, 9 schedule/*, and 5 loop/* request handlers, the three
rpc-error emitters (kept separate), and toScheduleSummary out of the Session
god-object into session/chat/chat-schedule-loop-session.ts, mirroring the shipped
CheckoutSession/VoiceSession deep-module-with-narrow-Host pattern. The least-coupled
remaining domain: stateless request/response over chatService/scheduleService/
loopService, reaching the shell only through a narrow ChatScheduleLoopSessionHost
(emit + agent-roster reads + the mention-fanout send). session.ts drops ~590 lines.
The two dispatch switches collapse into one: schedule/* was previously reached only
via the chat dispatcher's default fall-through arm — now all 21 types are explicit
delegation cases, removing that fragility.
Behavior-preserving: a new routing test drives the real Session.handleMessage for all
21 types (guards against a silently-dropped case), green before and after the move; a
collocated subsystem test covers the mention-fanout send seam, the fanout-limit error
code, the self->agent target remap, and the toScheduleSummary runs-stripping.
* refactor(server): extract provider catalog handlers into ProviderCatalogSession
Move the 8 provider-catalog handlers (model/mode/feature listing, providers
snapshot pull/refresh, diagnostic, usage) plus emitProviderDisabledResponse,
getProviderSnapshotEntryForRead, buildDraftAgentSessionConfig, the two mode-icon
downgrade helpers, and the providers_snapshot_update PUSH wiring out of session.ts
into session/provider/provider-catalog-session.ts behind a narrow
ProviderCatalogSessionHost seam — the same deep-module pattern as ChatScheduleLoopSession,
CheckoutSession, and VoiceSession.
The PUSH (start) and every PULL gate provider visibility and downgrade mode icons
through the SAME injected predicates (isProviderVisibleToClient + supportsCustomModeIcons),
both reading appVersion/clientCapabilities live — the COMPAT invariant the shell could
only enforce by code proximity before. dispatchProviderMessage collapses to delegation;
start()/dispose() wire into subscribeToOptionalManagers/cleanup at the same ordinal.
session.ts drops ~372 lines. Behavior-preservation gate (provider routing through the
real Session.handleMessage) green before and after; full session.test.ts 115/115; new
collocated subsystem test covers the PUSH/PULL parity seam, the disabled-provider path,
and the usage/feature error envelopes.
* refactor(server): extract workspace file-access handlers into WorkspaceFilesSession
Move the file-explorer, file-upload, file-transfer-frame, project-icon and
file-download-token handlers out of session.ts into
session/files/workspace-files-session.ts behind a narrow WorkspaceFilesSessionHost
seam (emit/emitBinary/hasBinaryChannel), matching the deep-module-with-Host shape
used by CheckoutSession/ChatScheduleLoopSession/ProviderCatalogSession.
The new module owns the FileUploadStore and is injected the daemon's
DownloadTokenStore; the shell's dispatch cases and the file_transfer binary route
collapse to delegation. The five handlers touch no workspace-git observer,
registry or subscription state, so this is a clean self-contained slice of the
workspace domain. session.ts drops 240 lines (7316 -> 7076).
Behavior-preservation tests (file explorer list/read/binary, download token
success + empty-cwd, project icon, upload round-trip) added to session.test.ts and
run green against the pre- and post-refactor Session; a collocated
workspace-files-session.test.ts exercises the module via a fake host with real
upload/download stores (no module mocks).
* refactor(server): extract agent-config setters into AgentConfigSession
Move the four agent-config setters (set_agent_mode/model/feature/thinking)
out of the session god-object into session/agent-config/, collapsing their
four near-identical try/log/emit envelopes into one applyConfigChange helper
behind a narrow { emit } host plus an AgentConfigOperations port.
Behavior-preserving: the dispatch cases delegate unchanged, setAgentModeCommand
still routes the mode path, and model/feature still emit no notice. Adds the
previously-absent failure-envelope coverage: 8 gate tests in session.test.ts
(success + forced failure per setter, asserting the *_response envelope and the
activity_log-before-response ordering) green before and after the carve, plus a
collocated agent-config-session.test.ts (typed fake operations + fake host, no
module mocks). session.ts drops ~173 lines.
* refactor(server): extract project-config handlers into ProjectConfigSession
Move read/write_project_config handlers, their failure emitters, and the
root-resolution helpers (resolveKnownProjectRootForConfig, canonicalizeConfigRoot,
stripTrailingPathSeparators) out of session.ts into session/project-config/, behind
a narrow { emit } host plus a projectRegistry port — matching the established
session/<domain>/ pattern. Also corrects a misfiling: project-config was dispatched
inside dispatchAgentConfigMessage despite being its own domain.
session.ts drops 155 lines (6903 -> 6748). The handleMessage-driven gate in
session.test.ts (project config RPC authorization) is green before and after; new
collocated project-config-session.test.ts covers the subsystem with a fake host and
fake registry over a real filesystem (no module mocks).
* refactor(server): extract daemon status/pairing handlers into DaemonSession
Move the two daemon.* RPC handlers (daemon.get_status, daemon.get_pairing_offer)
out of session.ts into session/daemon/daemon-session.ts behind a narrow
{ emit } host + injected reads (paseoHome, serverId, daemonVersion,
daemonRuntimeConfig, listProviderAvailability). These were misfiled inside
dispatchAgentConfigMessage despite being daemon- not agent-scoped; the slice
also removes the now-dead serverId/daemonVersion/daemonRuntimeConfig fields and
the getPidLockInfo/generateLocalPairingOffer imports from the god-file, and
collapses the inline daemonRuntimeConfig type into a named DaemonRuntimeConfig.
The daemon-config get/set handlers stay inline: daemonConfigStore is a
listener-bearing, cross-domain hub (read by structured-generation,
websocket-server, bootstrap, archive-if-safe), so moving it would double-own it.
session.ts: 6748 -> 6662 lines. Gate: new session.test.ts daemon round-trips
(status success + listing-rejects fallback, pairing relay-disabled) pass before
and after; new collocated daemon-session.test.ts (fake host + injected reads
over a real temp dir, no module mocks) 4/4. typecheck/lint/format clean.
#1640 extracted the voice subsystem to server/voice/ (a peer of session.ts);
#1644 extracted the checkout read side to session/checkout/. The two carves
landed in inconsistent homes, so there was no single place to find "what has
session.ts been carved into."
Relocate server/voice/ -> server/session/voice/ so all session subsystems share
one home alongside session/checkout/. Pure relocation: git-tracked renames with
import-depth bumped one level (../ -> ../../); the only external touch point is
the import path in session.ts. Behavior preserved verbatim — the 17 voice unit
tests pass unchanged before and after.
Also record the actual shipped layout and the next carve (the checkout mutation
handlers still inline in session.ts -> session/checkout/checkout-session.ts) in
the decomposition plan, and fix its now-stale voice path.
Extract the checkout read & live-stream side (status, branch validate/suggest, manual refresh, diff + status subscriptions) out of session.ts into session/checkout/checkout-session.ts behind a narrow CheckoutSessionHost seam and a CheckoutDiffSubscriber port. session.ts -234 net lines; 12 unit tests via injected fakes (no module mocks); 6 session tests moved onto the public handleMessage path. Also fixes a pre-existing tilde-resolution bug in the refresh handler (TDD).
* refactor(app): move workspace setup-status fetch into the setup store
The workspace screen ran a fetch-once-and-store workflow inline in a
useEffect, deduped by a hidden requestedWorkspaceSetupStatusKeyRef state
machine and a manual cancellation flag. The fetch + dedup + response
validation only existed inside the component, so it could only be
exercised through E2E.
Move the workflow into useWorkspaceSetupStore as an idempotent
ensureSetupStatus action with the daemon client injected as a port. The
store owns the in-flight dedup (requestedKeys) and clears the marker on
error/removal so a later attempt retries. The component effect now just
delegates; the ref and cancellation flag are gone.
This makes the workflow unit-testable with a fake client (no mocks) and
is the first slice of pulling business logic out of the workspace-screen
god component.
* fix(app): release the setup-status in-flight marker on every settle
Greptile P1: when fetchWorkspaceSetupStatus returned a null snapshot or a
mismatched workspaceId, the key stayed in requestedKeys forever, so a later
mount could never retry — a regression versus the old component-scoped ref,
which reset on remount.
Make requestedKeys a pure in-flight marker: add it before the fetch and
release it in a finally once the request settles (success, ignored, or
error). A settle that stored no snapshot leaves no marker, so the next call
retries; once a snapshot lands, the snapshots[key] guard prevents redundant
refetches. This also lets removeWorkspace/clearServer drop their now-redundant
requestedKeys pruning.
Adds tests for retry-after-null-snapshot and retry-after-mismatched-workspace.
* refactor(server): extract voice mode subsystem into VoiceSession
session.ts was a 10.5k-line god object and the most-churned file in the
repo. ~1,100 of those lines were an entire voice/audio subsystem — the
STT/TTS/dictation managers, the barge-in audio-buffering state machine,
voice-turn orchestration, and the MCP voice bridge — interleaved field by
field and method by method with workspace/git/agent/provider concerns.
Move the whole subsystem into a new VoiceSession deep module. Session now
holds one `voiceSession` field, constructs it once, and delegates the
voice/dictation/abort message types, the permission auto-allow gate, and
cleanup to it. VoiceSession owns all 18 voice fields and ~25 methods and
reaches back into the agent run only through a narrow VoiceSessionHost
seam (emit, loadAgent, reloadAgentSession, sendSpokenInput,
interruptAgentIfRunning, hasActiveAgentRun).
Behavior is preserved (verbatim method moves); deleting voice-session.ts
now removes the feature mechanically. The voice unit tests drive the
VoiceSession boundary instead of reaching into Session internals, so
future voice tests can construct a VoiceSession with a fake host.
session.ts: 10,468 -> 9,272 lines.
* refactor(server): move VoiceSession into voice/ and test it at the boundary
Address Greptile review on #1640:
- Move voice-session.ts into the existing voice/ subdirectory, alongside
voice-turn-controller.ts, instead of adding another peer to the 30+ file
server/ directory. The directory now carries the domain.
- Add voice/voice-session.test.ts beside the module: it constructs a
VoiceSession with a fake VoiceSessionHost and drives it through the public
API (handleSetVoiceMode, then turn-detection/STT events), asserting on the
host seam (sendSpokenInput) and emitted messages. No Session, no private
field/method access.
- Remove the voice tests from session.test.ts that reached through Session's
private voiceSession field into VoiceSession internals.
Same three behaviors are covered (streaming final -> agent, finalization
timeout empty path, low-confidence filtering); driving handleSetVoiceMode
additionally exercises the enable path the old tests bypassed.
Covers providers showing "Not installed", the login-shell/PATH
mismatch behind agents and terminals not seeing your commands, and
restarting the daemon after config changes.
Commit 25252d1b8 added AgentManager.unarchiveSnapshot, now called from
the shared unarchiveAgentState helper. The hand-rolled manager doubles in
the session-workspaces and import-sessions suites were not updated, so the
refresh/import RPC paths hit an unstubbed method and failed. Add the
method to those doubles; no production change.
The wire schema already allowed completed-turn usage, but rejected active usage updates before clients could see them. Accept usage_updated payloads and preserve turn ids on streamed turn events.
Sidebar project lists can be partial while hosts or workspace data are changing. Preserve collapsed preferences instead of treating a temporarily missing project as a request to expand it.
Unarchive provider storage before clearing Paseo's archived flag so failed native restores keep the snapshot archived. Keep initial history sync errors visible instead of falling back to the loading state.
Task-level usage reports processed work, not the parent session's active context. Prefer current context data and parent stream usage before falling back to result totals.
Record provider-owned helper processes (currently the OpenCode `serve` helper) in a daemon ledger and reconcile it in the background on startup: terminate validated leftovers, drop dead or PID-reused records without killing anything, and keep a record whose process can't be inspected for the next reconcile. Termination stops as soon as the process exits instead of always escalating to SIGKILL.
Calm the usage popover and settings card: smaller, muted provider logo
and name, more space between the header and the usage bars.
The popover divider used the `border` token, which equals the popover
background in dark themes and was invisible. Switch it to `borderAccent`
(the token the popover outlines itself with) and span it edge to edge.
Shrink the context meter ring and, while a session is active but usage
hasn't arrived yet, render a track-only ring in the final footprint so
the real ring fades in without shifting siblings. Agents that never
report usage still render nothing.
Expose the workspace id from worktree creation and let agent creation attach to an existing workspace, so callers can split worktree setup from agent launch without reusing the caller workspace.
Keep worktreeSlug as the worktree path slug and add branchName for branch-off targets.
Resolve exact suffix path queries before falling back to broad search, and keep broad hidden-directory traversal limited to known workspace config dirs.
Require relationship and workspace on create_agent, and reuse the same worktree target union for create_worktree. Agent-scoped prompt follow-ups now default to background finish notifications so callers can continue without polling.
When the Paseo daemon is launched as a macOS GUI app (via launchd), its
working directory is `/`. The `opencode serve` process inherited that cwd,
causing opencode's fff_search to scan and watch the entire filesystem,
leading to 300%+ CPU and 1GB+ RAM usage.
Fix: pass `cwd: os.homedir()` when spawning `opencode serve` so it never
inherits `/`. The per-session working directory is unaffected — it is
passed separately via the OpenCode SDK's `session.create({ directory })`.
Fixes#1617
* feat: live provider quota panel (Claude + Codex)
Adds Claude and Codex plan usage to the context window percentage circle tooltip, querying their respective APIs directly using existing CLI auth tokens.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix: address review feedback - restore quota trigger, guard NaN date
- Re-add triggerFetch() on agent idle/error in agent status subscription
- Guard formatResetsAtLabel against NaN from malformed API date strings
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix: resolve composer conflict and stub subscribe in websocket tests
* feat(quota): add cursor, copilot, zai, grok, and kimi quota UI and improve type safety
* feat(quota): make Claude and Codex pluggable, and map additional quota/credits metrics
* security(quota): fix shell injection vulnerability by migrating exec to execFile
* revert: restore original scripts/dev.ps1 and packages/desktop/scripts/dev.ps1
* Fix i18n resource test expectation
* Fix quota tooltip empty-provider state
* Preserve zero values in Grok quota
* Fix empty quota fetch state
* Ignore quota frames in relay reconnect tests
* Persist refreshed Codex tokens to source auth file
* fix(quota): read Claude OAuth credentials from the macOS login Keychain
On macOS, Claude Code stores its OAuth credential in the login Keychain
(generic-password item, service "Claude Code-credentials"), not in
~/.claude/.credentials.json — that file usually does not exist there, so the
Claude provider's existsSync check failed and macOS users silently got no
Claude quota. Read it via the macOS `security` CLI (its ACL only trusts
/usr/bin/security to decrypt; a native Keychain read would prompt), and stay
read-only there since Claude Code owns the refresh/persist. Linux and Windows
keep using ~/.claude/.credentials.json unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Fix quota keychain timeout and local host reconciliation
* Fix terminal notification test quota stub
* Ignore quota frames in terminal notification tests
* fix(quota): bypass Claude token refresh on macOS Keychain-backed logins
* fix(dev): update local dev daemon port to 6768 on Windows scripts/dev.ps1
* fix(dev): bypass Unix dev-daemon.sh on Windows in dev.ps1
* fix(client): handle and dispatch provider_quota event in DaemonClient
* fix(desktop): fix PowerShell quote stripping in desktop dev.ps1 config seeding
* fix(desktop): execute config update via temp file to avoid PowerShell quoting bugs
* fix(desktop): fix concurrently command invocation on Windows in dev.ps1
* fix(desktop): resolve path escaping and environment setting syntax errors in dev.ps1 for Windows
* Add on-demand provider usage views
* Fix Cursor usage billing dates
* Move provider usage renderer coverage to e2e
* Use provider manifest for quota fetchers
* Add provider usage fetch timeouts
* Reshape provider usage fetchers
---------
Co-authored-by: ABorakati <ABorakati@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
Co-authored-by: lumingjun <lumingjun@bytedance.com>
* fix(opencode): trust discovered modes instead of always injecting defaults
When OpenCode reports its available agents, respect that list exactly
rather than seeding from DEFAULT_MODES first. This means users who have
intentionally disabled modes (e.g. Plan) in their OpenCode config will
no longer see those modes surfaced in Paseo.
DEFAULT_MODES is still used as a fallback when discovery returns nothing
(e.g. OpenCode unreachable or no agents configured).
* test(opencode): update mode discovery expectations
* test(opencode): expect discovered custom modes only
* fix: daemon warns instead of crashing on missing OpenAI speech credentials
validateOpenAiCredentialRequirements was throwing an error when
OpenAI speech providers were configured without credentials,
causing the daemon to crash before it could start serving health
checks. Changed to log a warning instead — the daemon starts
successfully and reports speech services as unavailable.
Closes#1367
* chore: address Greptile review feedback
- Remove duplicate warning in initializeOpenAiSpeechServices when
all credentials are missing (already warned by validate)
- Strengthen test assertions: check getListenTarget() after start
- Wrap daemon start/stop in try/finally for proper cleanup on failure
Remove projects through a daemon RPC so the sidebar can delete the project
record after archiving its active workspaces. Reopening a removed directory
recreates the missing project record so local projects keep their derived
basename, and History skips orphaned archived workspace rows.
Fixes#1583.
* Reapply app file-open changes on v0.1.87
* fix: resolve relative assistant file links in workspace
* fix: tighten file link and open intent parsing
* Remove line and column from editor open targets
* fix: drop column offsets from file links
Holding the workspace-jump modifier reveals numbered badges in the sidebar for jumping to a workspace (Cmd+1..9). On the desktop app the badges also appeared while holding Option or Control, neither of which performs the jump — only Cmd does on macOS desktop (Ctrl on non-Mac, Alt on web).
The keydown/keyup handler flagged badge visibility for Alt on every runtime and for both Meta and Control on desktop — broader than the active workspace.navigate.index binding. Gate the badges to the single modifier that performs the jump on the current runtime, via a new getWorkspaceIndexJumpModifierKey helper. Adds unit tests for the three runtime cases.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
PR badges in the sidebar rows, hover card, and right-sidebar PR tab now
show the number alone. The Scripts and Open-in-editor header buttons drop
their labels and gain a min height so they keep the same size icon-only.
Old daemons can report agents by cwd without workspace IDs. Build path-backed workspace rows for that compatibility path and reapply ownership to legacy snapshots so tab reconciliation keeps those agents visible.
Archiving a Paseo-owned worktree deletes its directory, so reopening its
agent previously dead-ended even after the workspace record came back —
there was no folder to return to.
Restore now recreates the worktree at its original path from the kept
branch (the branch is never deleted on archive), re-points the same
workspace record, and clears archivedAt — preserving the workspaceId so
the agent stays linked. The directory is recreated before the un-archive
so the reconciler can't re-archive it for a missing directory. A deleted
or already-checked-out branch surfaces a typed failure and leaves the
workspace archived.
Gated behind a worktreeRestore capability flag; older hosts get an
explicit "update your host" state instead of a misleading failure.
Reopening an archived agent previously dead-ended at "workspace not
found": the agent record was un-archived but its workspace stayed
archived and was never sent to the client.
The daemon now un-archives the owning workspace alongside the agent when
its backing directory still exists, and the app auto-fires the restore
ahead of the missing-workspace gate so the user lands back in the
workspace rather than a dead end.
Worktrees whose directory was deleted on archive are not yet restorable
(recreating them from their branch is a follow-up); the workspace record
now persists its base branch as groundwork for that.
* Keep connections alive through brief daemon slowdowns
The active connection's liveness check was a side effect of the
candidate-probe loop: a single 5s latency measurement doubled as the
death timeout, so a daemon that was alive but briefly stalled (e.g. a
busy event loop) was misread as dead and the connection was torn down.
Give DaemonClient its own self-scheduling heartbeat that solely owns
liveness (15s timeout, 2 consecutive misses), and make the probe
read-only on the active connection — it reads the heartbeat's last RTT
instead of pinging. Split the shared ping into a pure measurement path
(measureLatency) and a private liveness path, so nothing outside the
client can drive teardown.
* Don't let a candidate latency timeout count as a liveness failure
The heartbeat (livenessPing) and candidate ranking (measureLatency) share
one in-flight ping slot. If a heartbeat tick lands while a candidate
measurement is still in flight, the heartbeat dedupes onto it — and a
measurement timeout was then recorded as a liveness failure, nudging the
connection one step closer to a spurious teardown.
Tag each ping with whether its timeout may drive a liveness failure
(only the heartbeat sets it) and gate recordLivenessFailure on that flag,
so an adopted measurement probe can never contribute to teardown.
The new-workspace composer uses preserve-and-lock: it keeps the typed
text through workspace creation so it survives a failure. But the input
minimized its height on every submit, collapsing a multi-line draft to
one line while the text and loading spinner remained — clipped and ugly.
Gate the height-minimize on the same condition that clears the text, so
the box only collapses when the text actually goes away (agent panel),
not when it's preserved (new-workspace).
Picks up the snapshot reflow-after-resize fix from 4cc72a84e, whose
source change to renderTerminalSnapshotToAnsi (scrollback/grid wrap
flags) was never rebuilt into this generated bundle. Pure regeneration
via build-terminal-webview-html.mjs — no hand edits. The esbuild
module-path comment churn is cosmetic, from workspace dependency
resolution paths.
Hide the kind icon entirely when a workspace is idle, and give the
attention state a standalone dot instead of overlaying it on the icon.
Restructure the row into an icon column plus a content column so the
subtitle and PR badge align under the title without hardcoded indents.
Derive app keyboard offsets from one provider-owned shared value so chat content, composer surfaces, and floating panels share the same policy.
Treat progress-zero keyboard samples as closed, even if Android reports a stale nonzero height.
Metadata prompts (workspace title + branch, commit message, PR) mixed the
functional contract with style guidance in one block, and project-owner
instructions from paseo.json were appended after the defaults with an
"override where they conflict" notice — so custom wording competed with the
built-in style instead of replacing it.
Each prompt is now a non-overridable contract (what to produce, the JSON
shape, correctness/safety rules) plus style slots that paseo.json
instructions replace wholesale. Title and branch style are now separate
keys (metadataGeneration.title vs .branchName); previously branchName
instructions also shaped the title.
Auto-generated titles were full sentences that overflowed the sidebar.
The titling prompt now targets ~4 words, drops generic "do" verbs (Fix,
Add, Diagnose...), and keeps only specific operation verbs, with good and
bad examples.
* Restore changelog entries for betas
Beta users check release notes, so each beta now updates an in-place
CHANGELOG.md entry that promotion overwrites with the final stable entry
— no -beta.N heading is left behind. The release machinery already
parses beta headings and syncs them; this restores the policy in the
docs and the release-beta skill, reversing the docs side of 28415b354.
* Spell out in-place beta overwrite in the promotion checklist
The stable checklist said only "update CHANGELOG.md", so a promotion
followed by the letter could stack a fresh entry on top of the
-beta.N block — the stale heading the policy forbids. Make the
promotion path overwrite-in-place explicit, matching the beta checklist.
* fix(desktop): disable auto-install on quit for AppImage only
electron-updater's AppImageUpdater.doInstall() uses execFileSync with APPIMAGE_EXIT_AFTER_INSTALL when autoInstallOnAppQuit is true. For AppImages, this blocks the old process indefinitely because the new process has no installer step to exit from — the mv already completed the install.
Scoped to AppImage only (process.platform === 'linux' && process.env.APPIMAGE). .deb/.rpm/Windows/macOS keep auto-install-on-quit working as before.
* fix(desktop): keep AppImage filename stable across updates
electron-updater renames a versioned AppImage to a new path on update and
unlinks the old one. That orphaned the previous binary, broke desktop
shortcuts, and dangled the ~/.local/bin/paseo CLI symlink, which points at
$APPIMAGE. Dropping ${version} from the AppImage artifactName makes the
updater overwrite the file in place instead; deb/rpm keep versioned names.
Also documents why AppImage must not auto-install on quit (the blocking
execFileSync gated on APPIMAGE_EXIT_AFTER_INSTALL, honored only by
AppImageLauncher) and extracts that gate into a unit-tested helper.
---------
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
The local/worktree choice was unstored component state and reset on every
remount of the New Workspace screen (e.g. after creating a workspace).
Persist it in the create-form preferences (FormPreferences.isolation).
Also rename the app-layer "backing" to "isolation" to match its UI label.
Isolation is the create-time intent; it stays distinct from workspaceKind,
the git-derived persisted property, and the wire's source.kind.
The agent-facing MCP server (/mcp/agents) ran in stateful session mode,
keeping a StreamableHTTPServerTransport plus its full Server and Zod tool
schemas alive per agent connection, keyed by session id. The map was only
pruned on the SDK's onclose/onsessionclosed callbacks, which fire only on a
clean DELETE. Agents that are killed or exit without one never get reaped, so
dead sessions accumulate for the life of the daemon (observed ~77MB across 132
stale transports in a heap snapshot).
Listing and calling tools needs no cross-request state, so each request now
builds a fresh server + transport and tears both down when the response closes.
Per-request cost is ~0.6-0.9ms and ~760KB of short-lived allocation; nothing is
retained between requests. GET and DELETE return 405, which the MCP client
tolerates (it never receives a session id).
* Name agents by their first prompt line instead of an LLM summary
The workspace title already captures the task; the LLM-generated agent
title just restated the same prompt. Drop the generator and keep the
provisional title (first prompt line), so the agent label stays
deterministic and explicit titles still win.
* Remove the no-op agent-title setting and leftover generator plumbing
Deleting the agent-title generator left its whole surface dangling: a
configurable "Agent titles" project setting that no longer did anything,
dead create-path plumbing, generator-only import/MCP deps, and the mock
provider's agent-title parser. Remove all of it, plus a dead test mock
and timing sleeps that guarded the deleted background job. Replace them
with a no-mock lifecycle test asserting the title stays the first prompt
line across create and settle.
Old paseo.json files with metadataGeneration.agentTitle still parse via
schema passthrough — see COMPAT(projectMetadataAgentTitle).
* Remove a worktree when its last workspace is archived
Archiving is now workspace-centric: the UI always archives a workspace,
and a worktree's directory is removed only when its last referencing
workspace is archived (derived from ownership + a single reference
count). The "remove the worktree from disk?" prompt is gone; archiving
warns only when the workspace is dirty or has unpushed commits.
MCP archive_worktree and the CLI keep the explicit lower-level behavior:
archive every workspace backing the directory, then remove it.
Auto-archive-after-merge and create-agent auto-archive respect the
reference count like any other workspace archival.
The policy now lives in two modules - a server archive service and a
client archive hook - with the reference-count check in exactly one
place. The deleteWorktreeFromDisk request field is retained but ignored
for wire back-compat.
* Tidy archive-policy internals and close test gaps
Follow-up polish on the archive-policy consolidation; no behavior change.
Structure: the server archive service is renamed to reflect that it now
owns all archive policy (the file and its types no longer say "worktree"
where they mean "archive"); the client archive input derives the
workspace kind from the canonical type instead of re-listing it; the
not-found target is typed string|null instead of an empty-string
sentinel; a redundant workspace-update emit and the ambiguous
worktrees-root naming are cleaned up. Wire, SDK, and keybinding names are
deliberately left unchanged for back-compat.
Tests: re-home the archiving-state lifecycle and per-workspace snapshot
assertions; pin worktree-kind acceptance through archive_workspace, the
ignored deleteWorktreeFromDisk field, and three-workspace archive-all;
add a Playwright spec for the dirty/unpushed worktree archive flow (the
warning gates it, confirming removes the directory) and the bulk
remove-project confirm branch.
* Fix the hanging worktree-archive risk-warning e2e spec
The spec added in the previous commit hung deterministically, locally and
in CI. Two test-only bugs, no product change.
The archive click opens a synchronous window.confirm(); the spec
registered a passive dialog waiter but only dismissed the dialog after
awaiting the click, so the click never resolved (Playwright won't settle
a click while a dialog is open) - a circular wait. Answer the dialog
inline via page.once("dialog", ...) before the click resolves, matching
the proven worktree-archive spec.
The seed also committed on a never-pushed branch, so aheadOfOrigin was
null and the "1 unpushed commit" warning never appeared. Push the branch
to set its upstream before the local commit, so it reports exactly one
unpushed commit.
Verified: the spec now passes in ~6s (was a 45s timeout); both the
dismiss-gates and accept-removes paths run.
Cmd+N (Ctrl+N off mac) navigates to the new-workspace route through a
global handler keyed on the active host. The sidebar header row surfaces
the shortcut hint on hover, replacing the redundant "+" button that
duplicated the action in the Workspaces section header.
The hover card was previously gated to open only when a PR hint or diff
stat was available. It now opens on every desktop-web workspace hover so
users can see the workspace name, branch, and working directory. The CWD
is shortened using the existing shortenPath helper (/Users/... -> ~).
Agent project-placement and the fetch/list grouping still resolved a
workspace from the agent's directory, even though every agent now
carries a workspaceId. Walk workspaceId -> workspace -> project instead:
an agent whose cwd is shared with or nested under another workspace now
groups under its own workspace, not a directory-prefix match. The fetch
path's placement map is keyed by workspace id, closing the
same-directory collision.
resolveWorkspaceIdForPath survives only at the archive-by-path boundary
(auto-archive-on-merge, the MCP archive tool), where a client passes a
bare worktree path with no id; renamed to match that role.
- Add DropdownTrigger and ComboboxTrigger with an overridable chevron slot and a 1 px translateY caret nudge for consistent vertical alignment.
- Convert text-based dropdown/combobox triggers across model selector, agent controls, mode control, settings, and new workspace to use the shared components.
- Lay out New Workspace arguments horizontally on desktop without labels while keeping the mobile vertical stack; align the project icon left edge with the title.
Multiple workspaces can share one checkout directory. Agent and terminal
status, sidebar tabs, and ownership now attribute strictly by workspace ID,
so a running or blocked agent in one workspace no longer shows up on its
same-directory siblings.
A one-time startup migration backfills workspace IDs onto legacy agents, and
every agent and terminal is created with one thereafter. Directory lookups
remain only for genuinely folder-derived git facts (branch, diff) and the
archive-by-path adapter.
A finished or input-waiting terminal shows an attention indicator. The
focus heartbeat already asked the daemon to clear it, but the clear was a
no-op: it guarded on activity state === "attention", while the real
tracker encodes attention as state "idle" plus an attentionReason
("finished" / "needs_input"). The state is never literally "attention",
so the indicator only ever cleared when the agent started a new turn.
The worker proxy also had no way to reach the tracker's clearAttention():
it only spoke setActivity, and setActivity("idle") is short-circuited by
the tracker while a "finished" reason is set. Add a clearAttention worker
message that calls the existing manager.clearTerminalAttention(), and
guard both clear paths on attentionReason instead of state.
Any new-tab target (new agent, terminal, browser, or a terminal profile)
can be pinned as a quick-launch button. Pins are global client app state
(persisted), not daemon config, so the same set shows on every workspace;
profile pins resolve per host and are dropped where that profile is absent.
The sidebar mirror launches via a navigate-then-launch intent store rather
than calling the create handlers directly: terminal creation only works in
the mounted/focused workspace, so the sidebar requests the launch and the
workspace screen consumes it on focus.
getTerminals was the only terminal read that round-tripped the terminal
worker process. Under load the IPC call could hit its 10s timeout, and
the rejection from a fire-and-forget workspace-update emit propagated as
an unhandled rejection — taking down the whole daemon.
The daemon already maintains an authoritative mirror of terminal state
(every other read is served from it), so getTerminals now reads that
mirror locally, preserving the worker's descendant-path aggregation and
absolute-path guard. The getTerminals worker IPC request is removed
entirely, so no terminal read can stall on the worker anymore.
Also adds a defensive .catch to the two fire-and-forget workspace-update
sites, and hoists the duplicated assertAbsolutePath guard into one shared
helper in path-utils.
* feat(workspace): bind agents/terminals/status to workspaceId (Model B phase 1)
Model B EX-Phase 1: the binding layer. Records gain an additive
workspaceId that answers "which workspace does this belong to" without
replacing the existing required cwd.
- protocol: add optional workspaceId to agent/terminal records and a
workspaceOwnership capability under server_info.features.*
- server: resolve and persist workspaceId on agent and terminal
creation; carry it through agent-manager, projections, storage,
loading, sessions, and the terminal manager/worker pipeline
- workspace-directory / workspace-registry-model: derive and expose
workspace ownership; bind status to workspaceId
- client: surface workspaceId via daemon-client
- app: filter terminals and agent visibility by workspaceId, thread it
through workspace screen / terminal panel / session store
- tests: cover workspaceId binding across create, agent-manager,
mcp-server, workspace-directory, workspace-registry-model, and
agent-visibility
cwd stays required on every record; workspaceId is purely additive and
back-compatible (optional/defaulted), so old and new peers still parse
each other's messages.
Known follow-ups: none.
* feat(workspace): empty projects + editable titles, fix same-cwd terminal isolation (Model B phase 2)
- Address Phase 1 review fixes and add same-cwd terminal isolation e2e coverage
- Isolate terminal subscriptions per workspaceId so two workspaces sharing one cwd no longer cross-wire terminals (terminal-subscription-key)
- Add editable workspace title, decoupled from the backing directory/branch
- Persist empty projects (no workspaces yet) across daemon restarts
- Add e2e coverage: empty-project-persists (server + app), workspace-same-cwd-isolation, sidebar-workspace-rename
Known follow-ups: none
* feat(workspace): create multiple workspaces per directory — local or worktree (Model B phase 3)
- workspace.create RPC always creates a new workspace record; no directory dedup
- dropped directory dedup in open_project and createPaseoWorktree paths
- non-git directories are first-class: local-checkout workspaces no longer require a repo
- service-proxy collision resolved by defaulting to the owning workspace instead of failing
- new creation UI: choose backing directory (local checkout or worktree) per workspace
- e2e coverage for creating multiple workspaces over the same directory
Known follow-ups:
- COMPAT cwd->workspaceId resolver remains until client floor advances
- prune any remaining workspace==directory assumptions surfaced by usage
* feat(cli): paseo run workspace policy — bare run creates a workspace, --workspace/PASEO_WORKSPACE_ID target (Model B phase 4)
- A workspace is now the explicit home of a `paseo run`: run.ts resolves a
workspace before creating any agent, then stamps the agent with that
workspaceId — no run leans on createAgent's legacy cwd->workspace fallback.
- Precedence: --workspace <id> > $PASEO_WORKSPACE_ID > --worktree > bare run.
--worktree mints its own workspace and overrides the ambient
PASEO_WORKSPACE_ID; --worktree + --workspace is rejected upstream.
- New-workspace-per-bare-run: every bare run mints a fresh local-backed
workspace for its cwd rather than reattaching to an existing one in that dir.
- Env support: $PASEO_WORKSPACE_ID (exported by workspace terminals) targets an
existing workspace, same as --workspace.
- Help text: new --workspace option documents the default (new workspace per
run) and the env fallback; created runs print the workspace id plus a tip.
- e2e: cli-run-workspace-precedence.e2e.test.ts covers bare, distinct-per-cwd,
--workspace attach, and env attach against an isolated daemon.
Known follow-ups:
- createAgent's COMPAT cwd->workspace resolver stays for old clients; the CLI no
longer relies on it but it is not yet removable.
* feat(workspace): archive removes the workspace record, never the directory; last worktree ref offers disk delete (Model B phase 5)
- Archive is now scoped to a single workspace RECORD (by workspaceId), not by cwd/worktree path. It tears down only the agents and terminals owned by the target workspaceId.
- Sibling isolation: a directory can back multiple workspaces, so archiving one workspace no longer destroys a sibling workspace's agents, terminals, or directory.
- Decoupled on-disk worktree deletion into an explicit, last-reference-only option (deleteWorktreeFromDisk). The directory is removed only when the archived workspace was the last active reference to a Paseo-owned worktree; local checkouts are never deleted.
- Unified archive UI with a keep/delete prompt: "Keep on disk" is the default non-destructive choice, "Delete" removes the worktree directory.
- Protocol: new optional, defaulted deleteWorktreeFromDisk field (COMPAT-tagged, back-compat preserved).
- Tests: record-scoped archive e2e (sibling isolation, last-ref disk delete, sibling-keeps-directory) and an app e2e for the keep prompt.
Known follow-ups:
- Surface the keep/delete prompt's "delete" path coverage in app e2e (only the keep path is exercised today).
- COMPAT(worktreeDiskDeletion): drop the optional gate when floor >= v0.1.97.
* feat(workspace): uniform expandable projects + status inbox; merged worktrees cleaned, explicit-id archive (Model B phase 6)
- P5 archive fixes: worktree archive now targets an explicit workspaceId, and auto-archive-on-merge cleans the worktree directory from disk when it is the last reference.
- Dropped non-git project flattening: every project — git or non-git, single- or multi-workspace — renders as the same expandable parent.
- Every project is expandable and every workspace is archivable; each carries its own "+ New workspace" affordance regardless of kind.
- Added a status inbox grouping (Ready for review / Working / Done) where each workspace is bucketed independently by last update.
- The deepest sidebar level is the workspace row: tabs, agents, and terminals never appear in the sidebar.
- Added Model B sidebar e2e coverage proving the expandable-parent, no-leaf, and independent-status-bucketing invariants.
Known follow-ups: the inline header FolderPlus worktree shortcut stays git-only (canCreateWorktreeForProjectKind); non-git projects create workspaces via the "+ New workspace" row.
* fix(protocol): use literal :: separator in terminal subscription key
The committed blob contained a NUL byte as the separator instead of the
:: the comment documents, which broke text diffs and review tooling
(git flagged the file as binary). Functionally the key is an in-memory
Map key only, but source must stay text.
* feat(workspace): shared directory right-sidebar boundary + docs; final consolidation (Model B phase 7)
- Pin and document the directory-backed vs workspace-owned right-sidebar boundary:
same-cwd workspaces share directory-keyed git/PR/file surfaces but never share
workspace-owned drafts, attachments, or file-explorer state. New docs section in
architecture.md plus data-model.md keying convention and two glossary terms.
- Add e2e coverage for the boundary: same-directory-workspaces.spec.ts proves the
right sidebar is shared (a directory change appears in both same-dir workspaces)
while tabs/terminals stay independent per workspace.
- Accumulated follow-up fixes:
- MCP child agents created in a new worktree are stamped with the new worktree's
workspaceId (not the parent's, not unstamped), mirroring the session path so
workspaceId-scoped archive can find and tear them down.
- session-store mergeWorkspaces prunes a stale empty-project descriptor when a
workspace lands in that project, so it stops governing the project's metadata.
- CLI run: reject --worktree alongside an ambient PASEO_WORKSPACE_ID (not just
--workspace), so worktree resolution never races an existing-workspace select.
New run.test.ts pins the three validation outcomes.
- Full-branch reshape outcomes:
- Consolidate the four placeholder server_info.features flags (workspaceOwnership,
workspaceTitles, workspaceProjects, workspaceMultiplicity) into the single
workspaceMultiplicity capability gate.
- Remove the now-dead ensureLocalCheckoutWorkspace and its deps interface; explicit
creation always mints a new same-cwd record via createLocalCheckoutWorkspace.
- Resolve every Model B COMPAT marker from v0.1.X to v0.1.97.
- Drop the obsolete workspaceOwnership feature assertion from the same-cwd
isolation e2e; broaden a sidebar-shortcuts test to cover both project kinds.
- Add workspace-create-errors.e2e.test.ts pinning each early-reject error branch.
- typecheck, lint, format all green.
Known follow-ups: pre-existing COMPAT(rewind) markers still carry v0.1.X placeholders
(out of Model B scope, left untouched).
* test(workspace): de-slop Model B tests per audit-tests (reshape mocks/assertions)
Reshape Model B test changes flagged by audit-tests so they exercise real
behavior and observable state instead of mock scaffolding.
Categories fixed:
- Mocks -> real dependencies + persisted state: create.test.ts and
mcp-server.test.ts dropped hand-built AgentManager/AgentStorage mock objects
and mock.calls[...] assertions, now run the real AgentManager/AgentStorage
with a fake agent client and assert on the stored agent record (workspaceId,
parent label).
- Module-internal spying -> injected seam: acp-agent stopped spying on the
tree-kill module (vi.spyOn(treeKillModule, ...)). Added a ProcessTerminator
injection seam to ACPAgentClient/ACPAgentSession (production defaults to
terminateWithTreeKill); tests inject a typed FakeTerminator and assert on the
recorded children plus observable stream .destroyed state.
- Poking internals -> public API: acp close()/killTerminal/releaseTerminal
tests now create terminals through the public createTerminal API instead of
mutating internals.terminalEntries.
- Reimplemented production logic deleted: cli-run-workspace-precedence dropped
its inline copy of resolveRunWorkspace flag precedence (a fake reimplementation
it then asserted against) and now proves only the daemon behaviors the CLI
builds on; flag precedence stays covered in the CLI's own run.test.ts.
- Sleep -> poll: workspace-same-cwd-isolation replaced a fixed setTimeout with
expect.poll on the observed snapshot state to remove the race.
- Internal protocol-frame assertions removed: sidebar-workspace-rename dropped
the captureWsSessionFrames workspace.title.set.request assertions, relying on
the user-visible rename + reload checks already present.
* fix: green CI — new-workspace status bucket regression + branch-picker flow tests + sdk emptyProjects
Source regression:
- packages/app/src/screens/new-workspace-screen.tsx
- packages/app/src/screens/new-workspace-empty.ts
When a new workspace is created with an initial agent, optimistically merge it
with status "running" (statusEnteredAt now) so it lands in the "Working" bucket
instead of defaulting into the wrong bucket. The empty-workspace path passes
withInitialAgent: false so a bare workspace keeps its descriptor status.
Test-drift (intentional P3 flow change — backing picker now required):
- packages/app/e2e/new-workspace.spec.ts
- packages/app/e2e/helpers/new-workspace.ts
The reshaped creation flow requires choosing a backing ("New worktree") before
the branch / starting-ref picker is reachable. Branch-picker specs now call
selectWorkspaceBacking(page, "worktree") first, and the helper waits for the
worktree control to drop aria-disabled (it stays disabled until the checkout
status query confirms the project is a git repo) before clicking.
Test-drift (protocol field added — sdk emptyProjects):
- packages/client/src/index.test.ts
fetch_workspaces response now carries emptyProjects; the toEqual expectation
includes emptyProjects: [].
Flake hardening:
- packages/app/e2e/same-directory-workspaces.spec.ts
- packages/app/e2e/helpers/seed-client.ts
Out-of-band working-tree writes raced the daemon's debounced filesystem
watcher, so the UI could subscribe before the new file was in the git snapshot.
Force a checkout refresh (same path as the UI's manual refresh) to make the
write authoritative before asserting, removing the timing dependency.
* test(app): expect withInitialAgent:false in empty-workspace create call
The withInitialAgent flag (fix for the new-workspace Done-bucket regression)
added a field to the ensureWorkspace call; the empty-path unit test pinned the
exact args. Test-drift, not behavior — update the expectation.
* feat(app): stack new-workspace creation params (Project / Isolation dropdown / Base) with reserved base row + keyboard avoidance
Restructure the new-workspace creation screen into a vertical stack of
Project / Isolation / Base parameters instead of the previous mixed
layout.
- Isolation/backing is now a dropdown (Local vs New worktree) matching the
app's existing dropdown/combobox primitives, replacing the inline
segmented switcher.
- The Base (starting ref) row now reserves its space even when the backing
is Local and the picker is hidden, so switching backing no longer causes
the form to shift vertically.
- The form avoids the keyboard so the title input and submit stay visible
while typing on native and web.
- Updated the e2e new-workspace helper to drive the dropdown-based
backing selector and the reserved Base row.
* fix(app): simplify new-workspace form — stacked ghost rows above composer, no card/title, project-matched dropdowns
- Render the three rows (formStack) at the top, under the "New workspace"
heading and above the composer input, not in the composer footer.
- Drop the card/surface/border chrome; rows sit on the plain background.
- Stack Project, Isolation (multiplicity only), and Base; remove the title
field — the title is set server-side and create works with no user title.
- Each row is a Label immediately followed by its dropdown control; no
columns, fixed label widths, or reserved horizontal space, with the label
glyph aligned to the heading's text x.
- All three triggers share the ghost badge style of the Project control
(ProjectPickerTrigger / IsolationPickerTrigger / RefPickerTrigger +
Combobox); keep the workspace-create-backing-* test IDs.
- Omit "New worktree" from Isolation entirely when the project is non-git.
- Reserve the Base row height always but render nothing on Local backing,
showing the Base label + ref picker only for New worktree.
- Keep keyboard avoidance on the centered content.
- E2E: drive selectWorkspaceBacking via the Isolation trigger + Combobox and
remove the now-gone workspace-create-title-input usage and title field.
* feat(app): sidebar new-workspace entry points + non-git isolation hidden + project auto-select
- Q1: hide the Isolation control on non-git projects — gate the row on canCreateWorktree (multiplicity && selectedIsGit) so a project with no git checkout never offers a worktree backing choice.
- Q2: reset the stale project preselect across the reused 'new' screen — clear the manual picker choice on route project identity change so each route-driven navigation preselects its own project; align the nav verb to router.navigate.
- Q3: remove the per-project "+ New workspace" sidebar row and add one global "New workspace" entry above Sessions in both mobile and desktop sidebars (testID sidebar-global-new-workspace); creation stays reachable per-project via the existing git new-worktree icon.
- Q4: match the Sessions / New-workspace header button sizing to the workspace rows — SidebarHeaderRow icon md->sm, label fontSize base->sm.
- e2e: new new-workspace-entry.spec covering global entry, project preselect reset across reused screen, and non-git isolation hidden; update sidebar-model-b, empty-project-persists, workspace-multiplicity, and helpers for the removed per-project row.
* fix(app): group New-workspace/Sessions header (no divider, workspace-row sized) + remove banned useUnistyles
- Q5: wrap the New-workspace and Sessions header entries in a single sidebarHeaderGroup that owns one bottom divider, so the two rows sit tight together with no gap and no per-row separator (both mobile and desktop sidebars).
- Add a compact variant to SidebarHeaderRow: workspace-row sized (minHeight 36, surfaceSidebarHover, borderRadius.lg) with horizontal padding that aligns its icon/label with the Workspaces section title and the workspace rows below; the default header variant (settings Back-to-workspace) keeps its sidebar-height row and own separator.
- Remove the banned useUnistyles() from sidebar-header-row.tsx per docs/unistyles.md: theme-reactive icon color now goes through withUnistyles(Icon) + uniProps mappings; static sizing reads ICON_SIZE.
- Taste: new-workspace-screen dedupes the project-icon styles (single projectIcon/projectIconFallback/projectIconFallbackText) and replaces repeated magic numbers with BADGE_HEIGHT and a named fallback-font-size constant.
* fix(app): no layout shift on git<->non-git (reserve Isolation row) + symmetric sidebar divider spacing
- Q6: Isolation row reserves its height and renders an invisible spacer for non-git projects, matching the Base-row pattern, so switching between git and non-git projects keeps a constant form height with no layout shift.
- Q7: sidebar header group splits paddingVertical into paddingTop/paddingBottom so the Sessions-row-to-divider gap equals the divider-to-Workspaces-header gap, centering the divider.
* feat(app): rename Sessions to History (clock icon, Agent history header)
- Sidebar label now reads "History" with a clock icon (was Sessions / MessagesSquare)
- Sessions screen header now reads "Agent history"
- Updated i18n strings across all 6 locales (en, ar, ru, zh-CN, fr, es)
- Route and testIDs unchanged (sidebar-sessions, /sessions)
* fix(app): symmetric sidebar header padding (top == bottom), traffic-light inset preserved
The sidebarHeaderGroup wrapper (New-workspace + History rows) had paddingTop: theme.spacing[1] against paddingBottom: theme.spacing[2]. Equalize paddingTop to theme.spacing[2] so the header group's top padding matches its bottom padding. The divider stays centered since paddingBottom still matches WorkspacesSectionHeader's paddingTop. The desktop window-controls (traffic-light) spacer — paddingTopSpacerStyle plus TitlebarDragRegion — is a separate inset and is left untouched.
* fix(app): hover card shows branch; sidebar copy-branch copies the real branch (not the title)
- Add SidebarWorkspaceEntry.currentBranch, sourced from gitRuntime.currentBranch (normalized; detached HEAD/blank/missing -> null)
- Fix copy-branch smear: handleCopyBranchName copied workspace.name (the title); now copies the real currentBranch (guarded)
- Workspace hover card: add a branch row (GitBranch icon + branch name), shown only when it differs from the title
- Header branch-switcher left untouched; diff-pane re-home decision still pending
* feat(app): branch switcher moves into the git diff panel; header title is static (Model B coherence)
- branch switcher now lives in the diff panel Changes header on desktop+mobile
- workspace header title is a plain static title; branch removed from it
- no new workspace-screen header row
- git diff-stat unchanged, stays where it is
- no duplicate git actions
- unified descriptor name fallbacks via resolveWorkspaceName
- rename-then-switch e2e proves header title and real branch stay independent
* feat: hide dotfiles in file explorer
* refactor: rename to "hidden files" and read flag from store
Rename the explorer dotfile toggle to show-semantics "hidden files"
so copy and code share one cross-platform vocabulary, and stop
prop-drilling the flag now that it lives in the panel store.
- explorerShowHiddenFiles (default true) replaces explorerHideDotFiles;
i18n keys + translations switched from "dotfiles" to "hidden files"
- imperative helpers read usePanelStore.getState() instead of taking
the flag as a parameter; flag stays threaded only through the
reactive tree-row render path
- drop dead usePanelState exposure; collapse migration double-guard
* fix: address Greptile review on hidden-file path matching
- isHiddenExplorerPath no longer treats ".." segments as hidden
(only "." was exempt before); add tests for ".." and "../sibling"
- assert the showHiddenFiles i18n key alongside hideHiddenFiles
---------
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
Threads the configured app.baseUrl through daemonRuntimeConfig to generateLocalPairingOffer in the daemon pair RPC path, so `paseo daemon pair` honors a custom app.baseUrl when the daemon is already running.
Fixes#1494Fixes#1186
* fix(server): use terminateWithTreeKill for ACP agent child process cleanup
ACPAgentSession.close() was using child.kill("SIGTERM") which only
signals the direct child process (e.g. npx), leaving grandchild
processes (sh -c, node qodercli) alive as orphans. Over time these
accumulate and consume significant memory (~330MB each).
Replace all direct child.kill("SIGTERM") calls in ACP agent lifecycle
with terminateWithTreeKill(), which recursively walks the process tree
via /proc on Linux and taskkill /T on Windows. This aligns ACP with
OpenCode, Pi, and Codex providers which already use tree-kill.
Affected code paths:
- ACPAgentSession.close() — main child process
- ACPAgentSession.close() — terminal child processes
- ACPAgentSession.killTerminal()
- ACPAgentSession.releaseTerminal()
- terminateChildProcess() helper (used by closeProbe and init failure)
Removes the now-unused waitForChildExit() helper.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(server): address acp tree kill review
* test(server): cover acp release terminal tree kill
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* feat: show activity indicators for terminals and their workspaces
A terminal surfaces whether the agent running inside it is working, idle,
or waiting for input as a tab dot, and that state rolls up into the
owning workspace status — so an agent run inside a terminal reads the
same as a native agent. Finishing a turn raises a "Terminal finished"
notification that routes back to the workspace terminal tab.
Activity is reported by the agents, not parsed from terminal output:
Claude and Codex command hooks and an OpenCode plugin post coarse state
to a local daemon endpoint, and a source-agnostic per-terminal tracker
maps it to working/idle/attention. Hook installation is opt-in and off
by default because it edits your real agent config files; enable it under
a host's Terminal settings and Paseo installs the hooks globally, runs
them via `paseo hooks <agent> <event>`, and removes its own hooks when
you opt back out. Installation and reporting are idempotent and
fail-open, so a missing or failing hook never breaks the user's shell.
* Fix terminal activity routing and CI test
Test drift: anchor the provider-leak test to the repo root instead of process.cwd() so it passes under the server workspace CI runner.
Also restore auth-first JSON parsing outside the terminal-activity route and route subdirectory terminal activity to the deepest active workspace.
* Fix workspace resolver regression
Code drift: terminal activity routing needs parent-workspace prefix matching, but agent directory and historical fetches keep exact registered workspace semantics, including archived exact matches.
* Narrow terminal/draft workspace subscriptions to the fields they use
terminal-panel and the draft agent tab subscribed to the whole workspace
descriptor via useWorkspace but read only workspaceDirectory (plus projectKind
in TerminalPanel, plus id in the draft tab), so they re-rendered on any field
change of that workspace (status, diffStat, scripts). Switch them to the
existing useWorkspaceDirectory / useWorkspaceFields hooks so they re-render only
when the fields they actually read change. No behavior change.
* Make TerminalActivity.state forward-compatible
A strict z.enum rejects the whole payload when a newer daemon reports a
terminal activity state this client predates, breaking the protocol's
old-client-parses-new-daemon contract (the activity field has no .catch at
its embedding in messages.ts, so the failure bubbles up). Degrade unknown
states to "idle" (no indicator, no notification) via .catch, matching the
enum-with-catch pattern used elsewhere in the protocol. Add a forward-compat
regression test.
* Make workspace IDs opaque, independent of the filesystem path
Workspace IDs were the resolved checkout/worktree path, so code could
treat an ID as a path: prefix matching, deriving directories from it,
falling back to a path when a lookup missed. IDs are now opaque -
compared only by exact equality and never used as a path. Anything
path-shaped resolves the workspace record first and reads its cwd.
New workspaces get a generated `wks_` ID; existing path-shaped IDs are
read from disk and never regenerated, so there is no migration and no
change to the wire or persisted schemas. Groundwork for running
multiple workspaces in a single directory.
* Keep attachment scope key stable and align refetch test
The attachment scope key keeps `workspace=` instead of `wsid=` so existing
persisted drafts are not orphaned; the rename was cosmetic. The SDK refetch
test no longer asserts a filter, matching refetch fetching one page and
selecting by id client-side.
* Fix live agent updates in directories without a registered workspace
The opaque-ID work removed buildProjectPlacementForCwd's directory
fallback, so an agent running in a folder with no registered workspace
(e.g. a fresh non-git dir) produced a null placement. forwardAgentUpdate
then threw "Workspace not found", the error was swallowed by its catch,
and no agent_update was emitted — live model/thinking switches and status
updates silently stopped. Caught by the live-preferences e2e suite.
The fallback builds a directory-scoped project placement keyed by the
path. That key is a project grouping key (non-git projects group by
path), not a workspace id, so it stays within the opaque-id rule.
* Always run server worktree archive, even without a resolved workspace
Archiving bailed out entirely when the workspace was not found in the
client store, so a race or stale state could make "archive" do nothing
server-side with only a console.warn. The server archive is keyed by
worktreePath, which is always available, so it now runs regardless; only
the optimistic client-side updates (keyed by workspace id) are gated on
the workspace being resolved.
* Expect a directory-scoped placement for unregistered agent dirs
This unit test asserted the no-placement behavior reverted in the live
agent-update fix, which had broken live model/thinking switching. Update
it to expect the directory-scoped placement now emitted for an agent in a
directory with no registered workspace.
* Fix opaque workspace routing in app E2E
* Preserve workspace IDs during partial bootstrap
* Fix archive flows for opaque workspace IDs
* Stop treating opaque workspace IDs as filesystem paths
Workspace IDs are opaque (wks_<hex>), but several call sites still passed
the id where a directory was expected, which broke those flows for opaque
IDs. The branch switcher sent the id as a cwd to branch/stash/checkout git
operations, and server archive/reconcile cleanup keyed git-watch and
subscription teardown by id, leaking that state.
Git and filesystem operations now take the workspace directory; the opaque
id is used only for identity and cache keys. Archive/reconcile cleanup
routes through a single teardownArchivedWorkspace helper that keeps the key
split explicit: runtime store by id, git watch and subscription by cwd.
Path-derived grouping keys are renamed to directory keys, and the helpers
are split into workspace-identity and workspace-directory so the id-vs-path
boundary is obvious.
* Fix coding-agent terminal shortcuts not working on Windows
On Windows, opening a Terminal profile (Claude Code / Codex / OpenCode) did
nothing, and afterwards even a plain New Terminal stopped working until restart.
Three Windows-specific problems combined:
- A failed node-pty conpty spawn completes asynchronously on its conout worker
thread; the uncaught exception escaped the per-request try/catch and crashed
the whole terminal worker, severing every terminal. Add uncaughtException /
unhandledRejection guards so a single bad spawn keeps the worker alive.
- Profile commands were passed to conpty unresolved. conpty's CreateProcess
ignores PATHEXT, so bare codex (npm codex.cmd) wasn't found and .cmd/.bat
shims can't run directly. Add resolveTerminalSpawnCommand() to resolve the
real path and route .cmd/.bat through cmd.exe /c on Windows.
- winget-installed CLIs (e.g. Claude Code) live under the winget Packages dir
and aren't on PATH. findExecutable() now falls back to those known install
locations, so all providers and terminal profiles benefit.
Also surface a failed terminal create to the user via a toast instead of
silently dropping the error.
* Address review: simplify winget scan, narrow worker exception guard
- Drop the single-element WINGET_PACKAGE_BIN_SUBDIRS loop in executable.ts and
map package dirs directly to the root <name>.exe candidate.
- Remove the unhandledRejection handler in the terminal worker (only the
uncaughtException path is exercised by conpty's async spawn failure) and
expand the comment to explain the keep-alive trade-off and the absence of a
worker restart path.
* Refactor executable resolution out of utils
* Serialize terminal create requests in worker
---------
Co-authored-by: danniel <liminfhu@gmail.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
ACP-based providers (Kimi and other custom ACP agents, Copilot, Cursor) were
absent from the import session picker. Two causes, both in the import path:
1. Never queried. The import refactor (51d1d007) gated provider listing on
capabilities.supportsSessionListing and set it for claude/codex/opencode/pi
but not for ACP providers, so agent-manager skipped every ACP provider in
listImportableSessions — the picker showed 'no sessions' even though
listImportableSessions is fully implemented. Set supportsSessionListing on
DEFAULT_ACP_CAPABILITIES and COPILOT_CAPABILITIES. Agents that don't support
session/list still return an empty list at the runtime probe, so this only
controls whether the daemon queries them.
2. Ignored cwd. listImportableSessions never forwarded the requested cwd to the
agent's session/list call, so agents returned globally-recent sessions that
the import limit could truncate before the current directory's sessions were
reached. Forward options.cwd as the session/list cwd filter (with the
pagination cursor) so the agent filters by directory at the source.
* Eliminate spiky terminal lag under load
Terminal output stuttered during heavy output and when the daemon was
busy. Two compounding causes, both confirmed by measurement:
- Every 256KB of output, the daemon dropped pending frames and re-sent a
full cell-grid snapshot (cloned across IPC, stringified to JSON) — a
10MB build did this ~40 times. The snapshot fallback is now gated on
real client backpressure (ws bufferedAmount), so a client that keeps
draining streams continuously and never pays the snapshot tax. This
also removes the periodic GC hitch the churn caused.
- Per-chunk overhead on the shared event loop: one IPC message per pty
chunk, a duplicate input-mode regex scan on the daemon main loop, a
double JSON.stringify per outbound message, and 16KB string realloc
per chunk. Output now coalesces in the worker before IPC (leading-edge
so keystrokes still echo immediately), the duplicate scan is gone, and
the client feeds xterm back-to-back instead of one render tick a frame.
Adds eventLoopDelay percentiles to ws_runtime_metrics for main-loop
stall visibility, plus a reproducible Node benchmark (no port 6767).
Measured: echo p50 7.7ms to 2.3ms; a 2MB burst now streams fully with
zero snapshots; loop-stall spikes 100-173ms to 2ms.
* Guard trace-level check against partial logger stubs
The isLevelEnabled gate added for the emit() perf fix crashed every
session test that injects a hand-rolled logger stub (10 of them omit
isLevelEnabled). Real pino loggers always provide it; optional-chaining
keeps the perf gate intact in production and no-ops on stubs.
* Make recent-output exit-summary test deterministic
The added test self-exited the child immediately after writing 3000
lines, then asserted the newest line was in the exit summary — but the
summary reads the headless xterm buffer, which parses writes
asynchronously, so a loaded CI runner saw a stale buffer (line-2707 not
line-2999). Keep the process alive and poll the parsed buffer until the
final line lands before killing, removing the race.
* Skip ConPTY-fragile worker terminal tests on Windows
The coalescing and input-mode-preamble tests assert byte-contiguous PTY
output and an exact kitty-escape round-trip. Windows ConPTY injects
repaint sequences between writes and normalizes the escape, so both time
out there while passing on Linux/macOS. Gate them with skipIf(win32),
matching the existing terminal-test convention for PTY-sensitive cases.
* Apply terminal barriers immediately when no writes are pending
A barrier op (snapshot/restore/clear) only needs the sentinel-write gate
to wait out plain writes still parsing in xterm's buffer. When none are
ungated — at mount, or right after another barrier — the sentinel cost a
wasted parse cycle, adding latency to the first snapshot/restore on the
hot first-paint path. Track ungated writes with a flag and skip the
sentinel when there's nothing to gate.
* Fix mobile startup saved-host e2e
* Unskip Windows worker terminal coverage
* Tighten worker coalescing assertion
* Reset terminal ungated writes on unmount
* Fix terminal regressions found in adversarial review
Three issues this PR's terminal changes introduced:
- Worker snapshot could duplicate coalesced output. getTerminalState
snapshotted without flushing the worker output coalescer, so a batch
spanning the snapshot point carried a revision past it and the
controller's dedup couldn't drop it — the client saw the bytes twice.
Flush before snapshotting.
- Relay clients lost backpressure protection. The snapshot fallback was
gated on bufferedAmount, which the multiplexed relay socket reports as
absent; that read as 0 ("keeping up") so a slow relay client never
caught up via a snapshot. Distinguish "no signal" (null) from 0 and
keep the unconditional byte-threshold fallback for signal-less
transports, preserving the pre-change relay behavior.
- Recent-output buffer was no longer a hard cap. A single chunk larger
than the limit was retained whole; slice its tail.
Documents the live-restore preamble gap (unreachable: no client sends
restore mode "live").
* Skip input-mode preamble test on Windows ConPTY
CI confirmed Windows ConPTY normalizes away the kitty keyboard escape
the child writes, so it never reaches the worker's input-mode tracker
and the preamble stays empty — the test times out. ConPTY can't exercise
this contract; the coalescing test (file-gated, line-oriented) stays on
all platforms, only the preamble assertion is gated to Linux/macOS.
Attachment pills were drawn by four separate style sets, so files,
images, GitHub items, and reviews rendered at different heights in the
same tray. Move the frame, the two-line label, and the image thumbnail
into shared components so every attachment lines up at one height,
across both the composer and sent messages.
Sent-message images now open the lightbox, and file uploads work on
iOS and Android via expo-document-picker instead of being gated to
desktop.
description:Cut a beta release of Paseo. Use when the user says "release beta", "cut a beta", "ship a beta", "beta release", or "/release-beta". Betas are silent release candidates — no changelog, no website move, npm only on the beta dist-tag.
description:Cut a beta release of Paseo. Use when the user says "release beta", "cut a beta", "ship a beta", "beta release", or "/release-beta".
user-invocable:true
---
# Release beta
Read `docs/release.md` in the Paseo repo and follow the **Beta flow**section end-to-end. Run the**Beta release** completion checklist at the bottom of that doc.
Key rules the doc enforces — betas don't touch `CHANGELOG.md`, don't draft release notes, and publish npm only with the explicit `beta` dist-tag.
Follow the **Beta flow**and**Beta release** completion checklist in `docs/release.md` end-to-end.
description:Cut a stable release of Paseo (fresh patch or promote from beta). Use when the user says "release stable", "ship stable", "promote", "release:patch", "release:promote", or "/release-stable".
description:Cut a stable release of Paseo (fresh patch or minor, or promote from beta). Use when the user says "release stable", "ship stable", "promote", "release:patch", "release:minor", "release:promote", or "/release-stable".
user-invocable:true
---
# Release stable
Read `docs/release.md` in the Paseo repo and follow the **Standard release (patch)** flow if cutting fresh, or the **Beta flow** promotion step if promoting an existing beta. Run the **Stable release (or promotion)** completion checklist at the bottom of that doc.
The doc covers the changelog (required for stable), the pre-release sanity check (required for stable), and the post-release babysit pattern. Don't skip steps.
Follow the applicable flow and the **Stable release (or promotion)** completion checklist in `docs/release.md` end-to-end.
I'm a solo maintainer and don't always keep up with GitHub Issues daily. If something is urgent or blocking you, [Discord](https://discord.gg/jz8T2uahpH) is the fastest place to reach me.
Issues are only for bugs. Product discussions go in [GitHub Discussions](https://github.com/getpaseo/paseo/discussions) or `#product` in [Discord](https://discord.gg/jz8T2uahpH) — feature requests opened as issues get closed automatically.
description:Propose a new feature or a change to existing behavior.
title:"feat: "
labels:["enhancement"]
body:
- type:markdown
attributes:
value:|
Paseo is opinionated and maintained by one person. Feature requests are welcome, but they get evaluated against product fit, not just usefulness, and the bar is whether the change keeps the product lean enough for one person to maintain.
Big ideas are better discussed in [Discord](https://discord.gg/jz8T2uahpH) first. And please don't open a feature request and a PR at the same time, get alignment on the idea before writing code.
- type:checkboxes
id:prior-search
attributes:
label:Prior search
options:
- label:I searched existing issues and discussions, and this isn't already proposed.
required:true
- type:textarea
id:problem
attributes:
label:What's the problem
description:What are you actually trying to do, and why is the current behavior in the way?
placeholder:|
When I'm doing X, I want to Y, but Paseo currently Z.
validations:
required:true
- type:textarea
id:proposal
attributes:
label:What would solve it
description:A rough sketch of the change. Mockups, screenshots from other apps, or a short video are very welcome, especially for UI proposals.
validations:
required:true
- type:textarea
id:alternatives
attributes:
label:Alternatives you considered
description:Optional. What else did you try, and why doesn't it work?
Please follow this template. The PR template applies whether you opened the PR via the web UI, `gh pr create`, or any other tool.
If you're fixing an objective bug or a small focused issue, this should be quick. Big PRs without a prior issue or design discussion are likely to be closed or scoped down. See CONTRIBUTING.md.
You MUST read CONTRIBUTING.md before sending a PR.
-->
### Linked issue
Closes #
<!-- Bug fixes and behavior changes should reference an issue. Pure docs and refactors can skip this. -->
<!-- A short description of the change in your own words. What was wrong, what you changed, why it works. If you can't explain this briefly, the PR is probably too big. -->
<!-- A short description of the reasoning of this change in your own words. What was wrong, and how do you hope this PR fixes it. If you can't explain this briefly, the PR is probably too big. This description must be grounded in real user flows and framed as value provided to Paseo users. -->
### How did you verify it
### Goals
<!-- A bullet list of what this PR wants to accomplish, include requirements and acceptance criteria. This helps lock down the scope of the PR and prevent expanding scope over the intended goal. -->
### Non-goals
<!-- A bullet list of what this PR does not want to accomplish. Add any intentional trade offs taken. This helps lock down the scope of the PR and prevent expanding scope over the intended goal. -->
### QA
<!--
This is the section I read most carefully. I need to see that *you* tested this, not that the diff looks plausible.
@@ -29,15 +36,14 @@ This is the section I read most carefully. I need to see that *you* tested this,
- For UI changes: a screenshot or short video on every affected platform (mobile, web, desktop). UI claims without visual proof are not enough.
- For behavior changes: the actual steps you ran, and what you observed.
- For bug fixes: how you reproduced the bug before, and confirmed it's fixed after.
AI-generated PR descriptions are fine in principle. AI-generated *verification claims* with no actual testing behind them are not, and they're easy to spot.
Paseo is a mobile app for monitoring and controlling your local AI coding agents from anywhere. Your dev environment, in your pocket. Connects directly to your actual development environment — your code stays on your machine.
**Supported agents:** Claude Code, Codex, GitHub Copilot, OpenCode, and Pi.
**Supported agents:** Claude Code, Codex, GitHub Copilot, OpenCode, Pi, Antigravity, and Muse Code.
## Repository map
@@ -19,32 +19,74 @@ This is an npm workspace monorepo:
`docs/` is the source of truth for system-level and process-level knowledge. **"The docs", "check the docs", or "check the X docs" always mean this directory — not the web.** Look here before fetching anything online; the docs capture gotchas and conventions you cannot derive from the code or external sources.
At the start of non-trivial work, list `docs/` and skim anything relevant to the task. When you learn something meta worth preserving — a gotcha, a convention, a workflow, a piece of system context that will outlive the current task — update an existing doc or propose a new one. Code-level facts belong in inline comments next to the code; system, process, and gotcha-level facts belong in `docs/`.
At the start of non-trivial work, list `docs/` and skim anything relevant to the task.
| [docs/hover.md](docs/hover.md) | Hover — the canonical pattern (plain View + onPointerEnter/Leave, separate inner Pressable) and the three ways agents break it |
| [docs/unistyles.md](docs/unistyles.md) | Unistyles gotchas — `useUnistyles()` is forbidden, alternatives in order |
| [docs/floating-panels.md](docs/floating-panels.md) | Anchored popovers — Portal/Modal escape for Android, lifecycle gates, keyboard-shared-value, status-bar offset, the flash |
| [docs/file-icons.md](docs/file-icons.md) | Material icon theme integration for the file explorer |
| [docs/providers.md](docs/providers.md) | Adding a new agent provider end-to-end |
| [docs/forms.md](docs/forms.md) | Form architecture — non-React form model, form kit, load-state gating; the schedule form is the golden example |
| [docs/hover.md](docs/hover.md) | Hover — the canonical pattern (plain View + onPointerEnter/Leave, separate inner Pressable) and the three ways agents break it |
| [docs/unistyles.md](docs/unistyles.md) | Unistyles gotchas — `useUnistyles()` is forbidden, alternatives in order |
| [docs/floating-panels.md](docs/floating-panels.md) | Anchored popovers — Portal/Modal escape for Android, lifecycle gates, keyboard-shared-value, status-bar offset, the flash |
| [docs/menus.md](docs/menus.md) | The menu engine — popover vs sheet, submenu pages, hover intent, when a decision earns a submenu |
| [public-docs/hub/security.md](public-docs/hub/security.md) | Public Hub guide — trust boundaries, untrusted triggers, provider controls, and output authority |
### Writing docs
- **Integrate, don't append.** Find the doc that owns the subject and rewrite the part that is now wrong. The standard failure is finishing a task and adding a paragraph to the bottom of the closest-looking doc; ten tasks later the doc is a pile of paragraphs in discovery order. `docs/custom-providers.md` is what that looks like.
- **Don't document logic.** Prose that restates code drifts from the code and loses. Write down what the code can't tell you: why something is shaped the way it is, the gotcha that cost an afternoon, conventions nothing enforces, constraints that span packages or versions. If a reader could get it in two minutes by opening the file, cut it.
- **One fact, one doc.** Every other mention is a link. If you are about to write the same paragraph in two docs, one of them is a link.
- **Respect the layers.** `CONTRIBUTING.md` and this file name things and link out. Activity docs like `docs/qa.md` and `docs/testing.md` set the bar for a kind of work. Subject docs like `docs/unistyles.md` own one thing completely. A layer never re-explains the one below it.
- **One subject per doc.** If the subject doesn't fit in a sentence, split the doc. A section per provider, vendor, or platform is a table plus one worked example.
- **Delete.** Obsolete sections go. Prefer a `packages/app/src/thing.ts:120` reference over a pasted block.
- **New doc?** Add a row to the table above and link it from the docs that should send readers there.
- Code-level facts belong in comments next to the code, not here.
### Doc voice
Plain and short. Second person. State the rule, then the reason when the reason isn't obvious. Match the doc you're editing.
Do not:
- Write a sentence to land a point. "It's not X, it's Y", "That's not a Z, that's a W", and every other setup-and-punchline shape.
- Add a clause that only asserts importance: "and that matters", "which is what keeps it working", "this is critical".
- Use "honest", "robust", "seamless", "powerful", "simply", "just", "delightful".
- Restate something you already said, in different words, for emphasis.
- Hedge with "generally", "typically", or "you may want to" when the answer is "do this".
- Clear your throat: "It's worth noting that", "In order to", "This section covers".
## Quick start
@@ -64,17 +106,26 @@ Repo dev commands use checkout-local state by default. In this checkout, `PASEO_
See [docs/development.md](docs/development.md) for full setup, build sync requirements, and debugging.
## Release branches
When the user says "this goes to next", create or
retarget the PR to `next` and preserve that destination through delivery. Follow
[release branch discipline](docs/release.md#release-branch-discipline) for creating
and updating `next`, integrating it after a release, and releasing a hotfix from a tag.
## Critical rules
- **NEVER restart the main Paseo daemon on port 6767 without permission** — it manages all running agents. If you're an agent, restarting it kills your own process.
- **NEVER assume a timeout means the service needs restarting** — timeouts can be transient.
- **NEVER add auth checks to tests** — agent providers handle their own auth.
- **Before changing app routes, startup routing, remembered workspace restore, or active workspace selection, read [docs/expo-router.md](docs/expo-router.md).**
- **NEVER run the full test suite locally.** The test suites are heavy and will freeze the machine, especially if multiple agents run them in parallel. Rules:
- Run only the specific test file you changed: `npx vitest run <file> --bail=1`
- Never run `npm run test` for an entire workspace unless explicitly asked.
- If you must run a broad suite, pipe output to a file and read it afterward: `npx vitest run <file> --bail=1 > /tmp/test-output.txt 2>&1` then read the file.
- Never re-run a test suite that another agent already ran and reported green — trust the result.
- For full suite verification, push to CI and check GitHub Actions instead.
- Add tests to existing suites and reuse their npm scripts and CI jobs instead of creating feature-specific ones.
- **Always run typecheck and lint after every change.**
- **Build workspace packages before diagnosing cross-package type errors.** This repo consumes generated declarations across workspaces. If typecheck fails in a package that depends on another workspace, rebuild the owning stack first so `dist` declarations are current:
-`npm run build:client` — rebuild protocol and client declarations.
@@ -84,20 +135,11 @@ See [docs/development.md](docs/development.md) for full setup, build sync requir
- **Always use npm scripts for linting and formatting.** Do not run tools directly with `npx eslint`, `npx oxfmt`, `npx oxlint`, or package-local binaries. For targeted checks, pass file paths through the npm script:
-`npm run lint -- packages/app/src/components/message.tsx`
-`npm run format:files -- CLAUDE.md packages/app/src/components/message.tsx`
- **The protocol stays backward-compatible. Features don't have to.** Two separate contracts:
- **Protocol contract (always):** schema changes must not break parsing in either direction. An old client must still parse messages from a new daemon; a new daemon must still parse messages from an old client.
-New fields: `.optional()` with a sensible default or `.transform()` fallback.
- Test with: "does a 6-month-old client still parse this?" and "does a 6-month-old daemon still send something this client accepts?"
- **Feature contract (per-feature):** a new feature may require a new daemon capability. The client detects whether the capability is present and either runs the feature or shows "Update the host to use this." That's it.
- **No fallback paths.** Don't write a degraded version of a new feature that runs on old daemons. Don't fan out across legacy RPCs to simulate a missing capability. The user upgrades or doesn't get the feature.
- **No defensive branches scattered through the feature.** Capability detection happens in one place; downstream code reads a clean shape.
- **Capability flags live in `server_info.features.*`** with a single `// COMPAT(featureName): added in v0.1.X, drop the gate when floor >= v0.1.X` comment marking the cleanup site.
- Existing functionality keeps working across versions — that's the protocol contract doing its job. New-feature degradation is not the goal.
- **New RPCs use dotted namespaces with direction suffixes.** Follow [docs/rpc-namespacing.md](docs/rpc-namespacing.md): `domain.provider.operation.request` pairs with `domain.provider.operation.response`. Existing flat RPC names will migrate over time; don't add new ones.
- **All back-compat shims are tagged and dated for cleanup.** Every shim that exists for old-client/old-daemon support carries a `COMPAT(name)` comment with the version it was added in and a target removal date (typically 6 months out). One grep — `rg "COMPAT\("` — should produce the full list of cleanup work. Don't bury back-compat in untagged `??`-fallbacks or optional-chain tunnels — that's how it stops being deletable.
- **The protocol stays backward-compatible. Features don't have to.** Read [docs/protocol-compatibility.md](docs/protocol-compatibility.md) before touching `packages/protocol`. The short version:
- **Protocol contract (always):** an old client parses messages from a new daemon, and a new daemon parses messages from an old client. New fields are optional; never narrow, never remove, never require. Wire schemas stay pure — no `.transform()`, `.catch()`, or `.preprocess()`.
-**Feature contract (per-feature):** gate the capability once on `server_info.features.*`, then run the feature or tell the user to update the host. No fallback paths, no defensive branches.
-**Every shim is tagged.** `// COMPAT(name): added in vX, remove after <date>` at the site that has to be deleted. `rg "COMPAT\("` is the cleanup backlog; untagged back-compat is permanent by accident.
- **New RPCs use dotted namespaces with direction suffixes.** Follow [docs/rpc-namespacing.md](docs/rpc-namespacing.md): `domain.provider.operation.request` pairs with `domain.provider.operation.response`. Existing flat RPC names will migrate over time; don't add new ones.
## Platform gating
@@ -135,12 +177,12 @@ The app runs on iOS, Android, web (browser), and web (Electron desktop). Code is
Import as `@/hooks/use-audio-recorder` — Metro picks the right file automatically.
- **Use `.electron.ts` / `.electron.tsx` for Electron-only web modules.** Electron is still the Metro `web` platform, but desktop dev/build sets `PASEO_WEB_PLATFORM=electron`, so Metro first looks for `.electron.*` files and falls back to normal `.web.*` files. Use this when the implementation depends on Electron-only behavior such as `webviewTag`, desktop preload APIs, or the Electron bridge. Keep plain browser web in `.web.*`, and keep native fallbacks in the base file or `.native.*`.
```
components/
browser-pane.electron.tsx ← Electron <webview> implementation
browser-pane.web.tsx ← plain web fallback
browser-pane.tsx ← native fallback
desktop/browser/pane/
index.electron.tsx ← Electron <webview> implementation
index.web.tsx ← plain web fallback
index.tsx ← native fallback
```
Import as `@/components/browser-pane` — Electron desktop gets the `.electron.tsx` file, browser web gets `.web.tsx`, and native gets the native/base implementation.
Import as `@/desktop/browser/pane` — Electron desktop gets the `.electron.tsx` file, browser web gets `.web.tsx`, and native gets the native/base implementation.
- **NEVER use raw DOM APIs without `isWeb` guard.** DOM APIs crash native. Casting a RN ref to `HTMLElement` is a red flag — ensure the block is web-only.
- **NEVER use `onPointerEnter`/`onPointerLeave`.** They don't fire on native iOS.
- **Hover only works on web.** React Native's `onHoverIn`/`onHoverOut` on `Pressable` does NOT fire on native iOS/iPad — the underlying W3C pointer events are behind disabled experimental flags. For hover-to-show UI (kebab menus, action buttons), use `isHovered || isNative || isCompact` so the controls are always visible on native and hover-to-show on web.
Paseo is an opinionated product maintained by one person.
This guide is here to save us both time and help you find a useful way to contribute.
I read every issue and PR myself, and I am selective about what contributions I accept.
Paseo grows through bug reports, testing, workflow discussions, plugins, documentation, and people helping each other. The sections below explain where each contribution belongs and what to expect.
Good ideas still need to fit the shape of the product: a PR can be technically correct and still not belong in Paseo.
## Philosophy
Core product, design, architecture, and workflow changes are not accepted.
**Paseo has a lean, opinionated core built to be extended.**
Follow these rules if you want your PR to be merged:
The goal is a low floor and a high ceiling: a polished default experience that is easy to understand, with the flexibility to choose your providers, run on your own infrastructure, and build your own workflows. Paseo should remain self-hosted and respectful of your privacy and control.
- Keep it to one focused change
- Link to an issue
- Explain the problem you're solving
- Include repro steps if it's a bug
- Include QA/testing evidence
- UI changes need screenshots or video for every affected platform: iOS, Android, desktop, and web
- If you only tested one platform, say that clearly
The core continues to evolve through improvements to the shared experience. Specialized workflows and integrations belong in the extension ecosystem. Product, design, architecture, and workflow decisions remain with the maintainer.
Your PR will be closed if you do any of these:
Read the [product philosophy](docs/product.md) for the reasoning behind these choices and how Paseo develops.
- Bundle unrelated changes
- Fail basic checks like typecheck, formatting or linting
- Make product, design, or architecture changes without prior discussion
- Submit no evidence of testing
- Skip the linked issue
- Clearly fully AI-generated PR
## Build a plugin
## AI assistance
Most specialized workflows and integrations are better served by plugins.
AI in the loop is fine. The bar is whether _you_ tested the change and can explain why it works. A confident wall of AI prose with no evidence of testing is a red flag and will get closed.
Plugins let you build what you need, share it, and maintain it independently. Start with the [plugin documentation](https://paseo.sh/docs/plugins). For applications and integrations built around Paseo, see the [SDK documentation](https://paseo.sh/docs/sdk).
If an extension point is missing, describe the workflow in [Discussions](https://github.com/getpaseo/paseo/discussions). A reusable capability that enables several plugins may be a better addition than implementing one particular workflow in core.
## Report bugs
Open a [GitHub issue](https://github.com/getpaseo/paseo/issues) and fill out the bug report template.
Include:
- What you did, what you expected, and what happened.
- Reproduction steps, Paseo version, and platform.
- Relevant logs, screenshots, or a recording.
If an agent investigated, include the evidence and reproduction steps it collected. Its explanation of the cause still needs verification.
Focused fixes for reproducible bugs are welcome. If you submit one, follow the pull request and QA guidance below.
## Discuss workflows before proposing features
Product discussions, including feature requests, belong in [GitHub Discussions](https://github.com/getpaseo/paseo/discussions). Feature requests opened as issues will be closed.
Explain:
- What are you trying to do?
- How do you do it today?
- Where does Paseo get in the way?
- What would a better workflow look like?
This helps other people describe their needs and gives me useful context when deciding what to build.
There is no feature request backlog or commitment to implement a discussion. I may read and learn from it without replying.
## If you still want to submit a pull request
After considering plugins and discussing the workflow, you may still want to propose a change to core. Focused fixes for reproducible bugs are also welcome.
**Pull requests are closed by default.** I may reopen the ones I want to take forward. Submit one only if you are comfortable with it being closed without a detailed review or explanation.
Paseo receives more pull requests than I can responsibly review. Reviewing every submission and explaining every decision would consume the time available for developing the product.
I can prompt an agent to write code just as you can. The bottlenecks are choosing what to build, shaping it, verifying its behavior, and taking responsibility for its maintenance. A finished implementation still leaves that work to do.
If you choose to submit a PR, these are the basics to cover.
### What I look for in a core feature
- **Demonstrated demand.** Link a discussion with concrete examples of other users needing the workflow. A feature for a small, specialized use case belongs in a plugin.
- **Value across workflows.** The feature should improve how existing capabilities work together and benefit a broad set of users. A reusable improvement is more valuable than an isolated control for one task.
- **A coherent design across platforms and providers.** Shared capabilities should work across platforms and providers wherever applicable. Do not reshape a shared abstraction around one provider while leaving the others unsupported or inconsistent.
- **A finished experience.** Follow the [design guidelines](docs/design.md). The feature must look and feel right in Paseo, including loading states, layout stability, and interaction performance. PRs with janky or rushed interfaces will be closed. Design judgment remains with the maintainer.
### Keep the scope small
Submit one focused change. PRs adding more than roughly **3,000 lines of production code** are unlikely to be accepted. Prefer the smallest complete improvement that can be tested and understood on its own.
### QA evidence
Run the changed application or service and test the affected workflow yourself. Include detailed QA evidence so I can see what you exercised and what happened. PRs without this verification and evidence will be closed, whether they fix a bug or add a feature.
Explain the problem and link the relevant bug report or discussion. Include:
- Automated tests that exercise the changed behavior. A bug fix needs a regression test that fails on the broken version.
- The commands you ran and their output.
- A recording for interactive UI changes, or before-and-after screenshots for static changes.
- The platforms you tested and any affected platforms you could not test.
The [QA guide](docs/qa.md) explains the expected evidence and available tooling.
Using an agent is welcome. Sending it to implement a change and submitting its output without trying the result yourself does not meet this bar.
**Satisfying all of the above does not mean your pull request will be merged.**
### What happens to your contribution
If I take a contribution forward, I may narrow it, reshape it, or use it as a reference for my own implementation. **You will be credited for your contribution, including when I reimplement it.**
A closed PR does not necessarily mean the underlying problem was dismissed. It means I am not taking that submission forward. Detailed reviews, individual explanations, and follow-up discussions cannot be provided at this volume.
## Help the community
Testing betas, reproducing bugs, improving documentation, sharing plugins, and answering questions all help Paseo develop.
There is no formal process for becoming a maintainer. Consistent involvement and good judgment build the shared context needed to take on more responsibility.
[paseo.sh/download](https://paseo.sh/download) 또는 [GitHub 릴리스 페이지](https://github.com/getpaseo/paseo/releases)에서 다운로드하세요. 앱을 열면 데몬이 자동으로 시작됩니다. 별도로 설치할 것은 없습니다.
휴대폰에서 연결하려면 **설정 → 호스트 → 기기 페어링**을 여세요.
### CLI / 헤드리스 환경
CLI를 설치하고 Paseo를 시작하세요.
```bash
npm install -g @getpaseo/cli
paseo
```
Paseo가 로컬에서 시작된 뒤 기기 페어링을 위한 종단 간 암호화 릴레이를 켤지 묻습니다. 거절하면 TCP, Tailscale 또는 다른 VPN으로 직접 연결할 수 있습니다. 이 방식은 서버나 원격 머신에서 유용합니다.
자세한 설치와 설정은 아래 문서를 참고하세요.
- [문서](https://paseo.sh/docs)
- [연결 가이드](https://paseo.sh/docs/connectivity)
- [설정 레퍼런스](https://paseo.sh/docs/configuration)
### Docker
Docker에서 Paseo 데몬과 셀프 호스팅 웹 UI를 실행하세요:
```bash
docker run -d --name paseo \
-p 6767:6767 \
-e PASEO_PASSWORD=change-me \
-v "$PWD/paseo-home:/home/paseo"\
-v "$PWD:/workspace"\
ghcr.io/getpaseo/paseo:latest
```
컨테이너가 시작되면 `http://localhost:6767`을 여세요. 사용하는 에이전트 CLI를 기본 이미지에 추가한 뒤, 환경 변수나 영구 `/home/paseo` 볼륨으로 인증 정보를 설정하세요. 자세한 내용은 [Docker 문서](docs/docker.md)를 참고하세요.
## CLI
앱에서 할 수 있는 모든 작업은 터미널에서도 할 수 있습니다.
```bash
paseo run --provider claude/opus-4.6 "implement user authentication"
paseo run --provider codex/gpt-5.4 --worktree feature-x "implement feature X"
paseo ls # 실행 중인 에이전트 목록
paseo attach abc123 # 실시간 출력 스트리밍
paseo send abc123 "also add tests"# 후속 작업 전송
# 원격 데몬에서 실행
paseo --host workstation.local:6767 run "run the full test suite"
```
자세한 내용은 [전체 CLI 레퍼런스](https://paseo.sh/docs/cli)를 참고하세요.
## 스킬
스킬은 에이전트가 Paseo를 통해 다른 에이전트를 오케스트레이션하는 방법을 알려 줍니다.
```bash
npx skills add getpaseo/paseo
```
그런 다음 어떤 에이전트 대화에서든 아래 명령을 사용할 수 있습니다.
-`/paseo-handoff` — 에이전트 간에 작업을 넘깁니다. Claude로 계획을 세운 뒤 Codex에 구현을 넘길 때 이 기능을 씁니다.
-`/paseo-advisor` — 작업 자체를 넘기지 않고, 에이전트 하나를 조언자로 띄워 두 번째 의견을 받습니다.
-`/paseo-committee` — 서로 다른 관점의 에이전트 두 개로 위원회를 구성해, 한 발 물러나 근본 원인을 분석하고 계획을 세웁니다.
## 개발
모노레포 패키지 구성은 다음과 같습니다.
-`packages/server`: Paseo 데몬(에이전트 프로세스 오케스트레이션, WebSocket API, MCP 서버 제공)
-`packages/app`: Expo 클라이언트(iOS, Android, 웹)
-`packages/cli`: `paseo` CLI(데몬과 에이전트 워크플로)
-`packages/desktop`: Electron 데스크톱 앱
-`packages/relay`: 데몬과 클라이언트가 쓰는 릴레이 전송 및 암호화 패키지
-`packages/website`: 마케팅 사이트 및 문서(`paseo.sh`)
자주 쓰는 명령:
```bash
# 모든 로컬 개발 서비스 실행
npm run dev
# 개별 환경 실행
npm run dev:server
npm run dev:app
npm run dev:desktop
npm run dev:website
# 서버 스택 빌드
npm run build:server
# 레포 전체 검사 실행
npm run typecheck
```
## 스폰서
Paseo는 한 사람이 개발하며, 사용하는 분들의 후원으로 운영됩니다. [GitHub Sponsors](https://github.com/sponsors/boudra)로 후원할 수 있습니다. 회사는 매월 [Paseo를 후원](https://paseo.sh/sponsor#spot)하고 로고를 이곳과 paseo.sh 홈페이지에 게재할 수 있습니다.
<!-- Sponsor logos go here, in the same order as packages/website/src/data/sponsors.ts -->
## 관련 프로젝트
- [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — Elixir로 작성한 공식 분산형 릴레이
- [paseo-skins](https://github.com/huangguang1999/paseo-skins) — 커뮤니티 테마와 Agent Skill을 제공하고, 코드 수정 없이 쓸 수 있는 데스크톱 테마 로더
- [paseo-vscode](https://marketplace.visualstudio.com/items?itemName=hinnes.paseo-vscode) — VS Code 확장 프로그램
<img src="https://paseo.sh/mobile-mockup.png" alt="Paseo mobile app" width="100%">
</p>
> [!NOTE]
> I'm a solo maintainer and don't always keep up with GitHub Issues daily.
> If something is urgent or blocking you, [Discord](https://discord.gg/jz8T2uahpH) is the fastest place to reach me.
---
Run agents in parallel on your own machines. Ship from your phone or your desk.
Paseo is an open source agentic development environment for desktop, mobile, web, and CLI. 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.
- **Complete development workflow:** 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, and Pi through the same interface. Pick the right model for each job.
- **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
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.
Download it from [paseo.sh/download](https://paseo.sh/download) or the [GitHub releases page](https://github.com/getpaseo/paseo/releases). Open the app and the daemon starts automatically. Nothing else to install.
To connect from your phone, scan the QR code shown in Settings.
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
```
This shows a QR code in the terminal. Connect from any client. 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.
Run the Paseo daemon and self-hosted web UI in Docker:
```bash
docker run -d --name paseo \
-p 6767:6767 \
-e PASEO_PASSWORD=change-me \
-v "$PWD/paseo-home:/home/paseo"\
-v "$PWD:/workspace"\
ghcr.io/getpaseo/paseo:latest
```
Open `http://localhost:6767` after it starts. Extend the base image with the agent CLIs you use, then provide credentials through environment variables or the persistent `/home/paseo` volume. See the [Docker documentation](docs/docker.md) for full setup details.
## CLI
Everything you can do in the app, you can do from the terminal.
```bash
paseo run --provider claude/opus-4.6 "implement user authentication"
paseo run --provider codex/gpt-5.4 --worktree feature-x "implement feature X"
paseo run --provider codex/gpt-5.5 --worktree feature-x "implement feature X"
prompt:"Review the current diff and name the riskiest change.",
});
constresult=awaitagent.waitForFinish();
console.log(result.lastMessage);
awaitclient.close();
```
See the [SDK quickstart](https://paseo.sh/docs/sdk/quickstart), [recipes](https://paseo.sh/docs/sdk/recipes), and [API reference](https://paseo.sh/docs/sdk/reference).
## Skills
Skills teach your agent to use Paseo to orchestrate other agents.
@@ -116,7 +174,6 @@ npx skills add getpaseo/paseo
Then use them in any agent conversation:
-`/paseo-handoff` — hand off work between agents. I use this to plan with Claude and then handoff to Codex to implement.
-`/paseo-loop` — loop an agent against clear acceptance criteria (aka Ralph loops), optionally with a verifier.
-`/paseo-advisor` — spin up a single agent as an advisor for a second opinion, without delegating the work itself.
-`/paseo-committee` — form a committee of two contrasting agents to step back, do root cause analysis, and produce a plan.
@@ -128,7 +185,7 @@ Quick monorepo package map:
-`packages/app`: Expo client (iOS, Android, web)
-`packages/cli`: `paseo` CLI for daemon and agent workflows
-`packages/desktop`: Electron desktop app
-`packages/relay`: Relay package for remote connectivity
-`packages/relay`: Relay transport and encryption used by the daemon and clients
-`packages/website`: Marketing site and documentation (`paseo.sh`)
Common commands:
@@ -150,22 +207,17 @@ npm run build:server
npm run typecheck
```
## Community
## Sponsors
- [paseo-relay](https://github.com/zenghongtu/paseo-relay) — self-hosted relay in Go
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 -->
<img src="https://api.star-history.com/svg?repos=getpaseo/paseo&type=Date" alt="Star history chart for getpaseo/paseo" width="600" style="max-width: 100%;">
</picture>
</a>
</p>
## Related projects
- [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — official distributed relay, written in Elixir
- [paseo-vscode](https://marketplace.visualstudio.com/items?itemName=hinnes.paseo-vscode) — VS Code extension
<img src="https://api.star-history.com/svg?repos=getpaseo/paseo&type=Date" alt="Star history chart for getpaseo/paseo" width="600" style="max-width: 100%;">
@@ -22,7 +22,9 @@ The relay is designed to be untrusted. All traffic between your phone and daemon
1. The daemon generates a persistent Curve25519 keypair on first run and stores it at `$PASEO_HOME/daemon-keypair.json` with mode `0600`
2. The pairing URL (rendered as a QR code or opened directly) carries the daemon's public key in its URL fragment (`https://app.paseo.sh/#offer=...`). Fragments are not sent to the web server, so `app.paseo.sh` never sees the key.
3. When the phone connects via the relay, it generates a fresh ephemeral Curve25519 keypair and sends an `e2ee_hello` message containing its public key. The daemon will not process any application messages until this handshake completes.
4. Both sides perform a Curve25519 ECDH key exchange to derive a shared key. All subsequent messages are encrypted with XSalsa20-Poly1305 (NaCl `box`). The wire format is `[24-byte nonce][ciphertext]`, base64-encoded as a WebSocket text frame.
4. Both sides perform a Curve25519 ECDH key exchange to derive a shared key. All subsequent messages are encrypted with XSalsa20-Poly1305 (NaCl `box`). The encrypted bundle is `[24-byte nonce][ciphertext]`. Peers optionally negotiate `binaryCiphertext` in `e2ee_hello` / `e2ee_ready`: negotiated application text is carried as a base64 WebSocket text frame, while application binary is carried as a raw WebSocket binary frame. A peer that does not negotiate the capability uses base64 text frames for both kinds.
The WebSocket opcode is preserved end to end after negotiation; the receiver never guesses whether authenticated plaintext is text or binary from its byte contents. The plaintext handshake remains WebSocket text and contains only public keys and capability declarations.
The relay sees only: IP addresses, timing, message sizes, session IDs, and the plaintext `e2ee_hello` / `e2ee_ready` handshake frames (which contain only public keys). It cannot read message contents, forge messages, or derive encryption keys from observing the handshake.
@@ -40,16 +42,26 @@ The daemon requires a valid cryptographic handshake before processing any comman
The QR code or pairing link is the trust anchor. It contains the daemon's public key, which is required to establish the encrypted connection. Treat it like a password — don't share it publicly.
When a daemon password is configured, new relay clients send it in the encrypted `hello` message. This release still admits relay clients that send no credential so existing mobile builds continue to connect. A wrong password is rejected. The next release will require the password for relay connections after updated mobile builds are available.
## Local daemon trust boundary
By default, the daemon binds to `127.0.0.1`. With no password configured, the local control plane is trusted by network reachability — anything that can reach the daemon socket can control the daemon. This is the same security model Docker documents for its daemon: the security boundary is access to the socket or listening address.
By default, the daemon binds to `127.0.0.1`. With no password configured, anything that can reach the daemon socket can control the daemon. Loopback is reachable by other users on the machine and by some forwarding tools.
The daemon also supports an optional shared-secret password (set via `auth.password` in `config.json` or the `PASEO_PASSWORD` env var; stored bcrypt-hashed). When configured, every HTTP request must carry `Authorization: Bearer <password>` and every WebSocket upgrade must include a `Sec-WebSocket-Protocol: paseo.bearer.<password>` subprotocol. Browser WebSocket cannot set custom headers, which is why the token rides in the subprotocol. Health (`GET /api/health`) and CORS preflight (`OPTIONS`) are exempt. The password is intended for direct-TCP exposure (e.g. `tcp://host:port?ssl=true&password=...`); it is **not** a substitute for the relay's E2E encryption when traversing untrusted networks.
The daemon supports an optional shared-secret password (set via `auth.password` in `config.json` or the `PASEO_PASSWORD` env var; stored bcrypt-hashed). WebSocket clients send the password in `hello`; the daemon sends no session data before admission. Direct connections still accept bearer headers and WebSocket bearer subprotocols for older clients. HTTP stays bearer-header based. Health (`GET /api/health`) and CORS preflight (`OPTIONS`) are exempt; `/api/files/download` and `/mcp/agents` use their own capability tokens.
The daemon writes a new `$PASEO_HOME/local-credential` on every run with mode `0600` and removes it on shutdown. The CLI and desktop main process read it only for the daemon whose PID lock `listen` matches their connection target. A same-user process can read this credential, so the password protects against network clients and other OS users, not processes running as the daemon user. Protect `$PASEO_HOME` accordingly. Relay traffic remains end-to-end encrypted independently of password admission.
Connected clients are trusted operators of the daemon user. File previews follow that authority: a preview request may read any regular file the daemon process can read, while keeping path normalization and symlink checks in the daemon file service. Workspace-relative paths remain a UI convenience, not a security boundary.
When Paseo checks out a change request from a different repository, it does not run that workspace's `paseo.json` setup, automatic terminals, named scripts, or teardown until you explicitly run setup for that workspace. The decision lasts for the workspace and does not re-prompt after new commits. Same-repository changes, ordinary branches, local workspaces, agent launches, terminals, explicit shell commands, and metadata-generation instructions are outside this gate.
If you expose the daemon beyond loopback, such as by binding to `0.0.0.0`, forwarding it through a tunnel or reverse proxy, or publishing it from a Docker container, you are responsible for restricting and securing that access. Setting a password is strongly recommended in that case.
In Docker, the official image runs the daemon and agents as the non-root
`paseo` user by default. Mounted workspaces and credentials are still fully
available to anything the agents run inside the container.
For remote access, use the relay connection. It is the supported path for reaching the daemon off-machine, and it adds end-to-end encryption plus a pairing handshake before commands are accepted.
Host header validation and CORS origin checks are defense-in-depth controls for localhost exposure. They help block DNS rebinding and browser-based attacks, but they do not replace network isolation.
@@ -60,10 +72,26 @@ CORS is not a complete security boundary. It controls which browser origins can
Paseo validates the `Host` header on every HTTP request and every WebSocket upgrade against an allowlist (Vite-style semantics). By default, only `localhost`, `*.localhost`, and any literal IP address (IPv4 or IPv6) are accepted. Additional hostnames can be configured via `hostnames` in `config.json` or the `PASEO_HOSTNAMES` env var (comma-separated; entries beginning with `.` match a domain and its subdomains; the value `true` disables the allowlist entirely). Requests with unrecognized hosts are rejected with `403 Host not allowed`.
## HTML file preview
Previewing an `.html` file in the file pane renders it as a page, so markup an agent wrote — or markup that arrived with a repo you cloned — executes when you open it. The preview is built to contain that, not to trust it.
The document loads with an opaque origin and a policy that permits inline script and style and refuses everything else: no remote script, font, image, or media; no `fetch`, XHR, WebSocket, or beacon; no form posts; no plugins; no nested frames. It has no access to Paseo's DOM, and storage and cookie APIs throw inside it rather than returning anything. It cannot navigate the top window, and it cannot open popups. It cannot read any file but itself.
One gap remains on web and desktop: a sandboxed document may navigate _itself_, and no CSP directive in current browsers prevents that. `navigate-to` was dropped from CSP Level 3 and is not enforced, and `<meta http-equiv="refresh">` needs no script at all. A hostile page can therefore reach a server by navigating away, carrying data available inside the preview, such as its own contents, browser and device properties, user input inside the page, and your IP address. It cannot read Paseo, another file, storage, or cookies.
Native builds narrow this gap rather than closing it outright. The WebView refuses every navigation after the initial document, but that decision is made in the app's JavaScript, and on Android the WebView falls back to allowing a navigation when the decision doesn't come back in time. Treat it as a strong mitigation, not a guarantee: if the JS thread is stalled at the moment a page navigates, the same leak is possible there too.
If you don't trust a page, read it in `Source`, which executes nothing. Source is available as an editable view on supported web hosts and a read-only view everywhere else.
## Agent authentication
Paseo wraps agent CLIs (Claude Code, Codex, OpenCode) but does not manage their authentication. Each agent provider handles its own credentials. Paseo never stores or transmits provider API keys. Agents run in your user context with your existing credentials.
## Forge host trust
Paseo only talks to a forge host that is either a known cloud host or one the forge CLI is already authenticated to. It never probes or routes credentials to an unauthenticated, remote-derived host.
## Reporting vulnerabilities
If you discover a security vulnerability, please report it privately by emailing hello@moboudra.com. Do not open a public issue.
└────────┘ (agent completes a turn, awaits next prompt)
```
Each agent in `AgentManager` carries a `lastStatus` of `initializing`, `idle`, `running`, `error`, or`closed`. State transitions persist to disk and stream to subscribed clients via WebSocket.
Each live agent in `AgentManager` carries a `lastStatus` of `initializing`, `idle`, `running`, or`error`.`closed` is the persisted, resumable state for an agent record that has no live provider runtime. State transitions persist to disk and stream to subscribed clients via WebSocket.
## Runtime residency
An unarchived agent may be `closed` without being deleted or archived. Closing releases its provider
processes and subscriptions while retaining its Paseo identity, persistence handle, timeline,
workspace, labels, title, usage, attention, timestamps, and parent relationship. Opening or prompting
the agent runs through `ensureAgentLoaded()`, which resumes the durable provider session under the
same Paseo agent ID. Provider history is not appended again when the canonical timeline is already
primed.
Reload releases the old runtime before resuming its durable session: an idle provider process can
still own an exclusive writer. A close failure retains that runtime for cleanup and blocks the
replacement. Once closure succeeds, a failed resume leaves the durable agent closed and retryable.
Idle agents remain resident indefinitely. Runtime closure happens only through an explicit lifecycle
action such as archive, replacement, reload, workspace teardown, or daemon shutdown.
A provider runtime can still die on its own — crash, OOM kill, host suspend. Work the agent parked
inside that process dies with it: Claude Code's background Bash shells, `Monitor` watches, and
workflows all live in the CLI process, and the completion notification that would have woken the
agent never arrives. A runtime that dies mid-turn is reported by whatever is draining its stream, but
between turns nothing is watching, so the agent sits at `idle` looking healthy while its background
work is gone. Report that exit as a turn failure so the agent lands in `error` with a timeline entry.
Claude and OMP report exits between turns. OMP relaunches from its session file on the next prompt;
the unfinished turn is lost.
### Cancellation
Provider interruption is idempotent at the `AgentSession` boundary. It resolves when the prior
foreground turn can no longer run, including when the provider reports that it is already idle. It
rejects only when the provider may still own the turn. Provider adapters translate native errors
into that contract; lifecycle callers do not interpret provider-specific errors.
After an acknowledged interrupt, the manager settles the captured run even when no terminal event
arrives or the run was still waiting for its provider turn id. The captured run token prevents an
older cancellation from settling a newer turn. If interruption is rejected or times out, the agent
keeps its active foreground turn and replacement, reload, rewind, and Stop report the failure.
Accepting new work after an ambiguous interruption would create a split-brain session.
## Relationships
Agents can launch other agents via the agent-scoped `create_agent` MCP tool. Agent-scoped creation is always asynchronous. By default, the daemon stamps the created agent with a label`paseo.parent-agent-id` pointing back at the agent that created it. The client surfaces that as `agent.parentAgentId`.
Agents can launch other agents via the agent-scoped `create_agent` MCP tool. Agent-scoped creation is always asynchronous and always stamps`paseo.parent-agent-id`, pointing back at the caller. Omit `workspaceId` to use the caller's workspace, or pass an existing workspace ID returned by `create_workspace`. Placement never changes parentage.
Agent-scoped `create_agent` accepts `detached: true` for agents that should stand on their own. The daemon still uses the creating agent for cwd/config inheritance, but does not write `paseo.parent-agent-id`.
- **Subagents** — exist as part of the creating agent's work, appear in that agent's subagent track, and are archived with it.
- **Detached agents** — stand on their own after an explicit detach transition, do not appear in the former parent's subagent track, and are not archived with it.
- **Subagents** — created with `detached: false` or omitted. They exist as part of the creating agent's work, appear in that agent's subagent track, and are archived with it.
- **Detached agents** — created with `detached: true`. They take over as sibling/root agents (e.g. handoffs, fire-and-forget delegations), do not appear in the creating agent's subagent track, and are not archived with it.
Parent archive detaches a subagent instead of archiving it when either condition holds:
`notifyOnFinish` defaults to `true` for agent-scoped creation because most subagents are delegated work the creating agent needs to hear back from. Set it to `false` only for truly fire-and-forget agents.
- The child belongs to another workspace.
- The child is currently open in an agent tab.
All other children archive with the parent. After the workspace layout hydrates, the client marks
every managed subagent present in its tabs with `paseo.open-agent-tab.<client-id>=true` through the
generic agent metadata update. This includes background and restored tabs; navigation does not own
the marker. Closing a tab sets that client's label to `false`. Any `true` client label keeps the child
open. Detach clears the parent and every open-tab label. The surviving child therefore becomes a
normal root agent immediately, and closing its still-open tab archives it.
Runtime ownership is resolved from explicit workspace ID and caller context, never from `cwd`. Workspace creation is a separate operation with `local | worktree` isolation; agent creation reuses that workspace unless a new workspace is explicitly requested.
Users can also detach an existing subagent from the subagents track. Detach is deliberately a manual lifecycle gesture, not an agent-facing MCP tool. It removes the parent and open-tab lifecycle labels: it does not stop, archive, move, or restart the agent. The agent keeps its current `cwd` and `workspaceId`, leaves the former parent's track, and behaves like a root agent for tab close, workspace activity, and future parent archive.
`notifyOnFinish` defaults to `true` for agent-scoped creation and prompt follow-ups because most delegated work needs to report back to the creating agent. Set it to `false` only for truly fire-and-forget agents or prompts.
Permission requests are notification checkpoints, not the end of that subscription. The caller is notified again after a permission response when the child finishes, errors, or requests another permission.
The permission notification includes the normalized request plus the child and request IDs, so the caller can inspect it and respond without fetching agent status.
A watched child that closes before its finish event also notifies the caller so delegated work cannot disappear silently during archive or workspace teardown.
## Provider-managed child agents
Some providers can create their own child sessions inside one provider runtime. OMP's task tool reports these with `child_session` events; `AgentManager` imports the live provider handle, stamps `paseo.parent-agent-id`, and surfaces the result as a normal subagent in the parent's subagents track.
The provider still owns the underlying runtime. Paseo keeps an agent record so the child can be opened, tracked, archived, and cascaded with the parent, but prompts and history hydration route through the provider adapter for that native child handle.
agents and terminals: provider history, plugin hooks, parentage, archive, and restart recovery
keep their normal behavior. Visibility is creation-only; selecting an existing workspace cannot
change it. New workspaces default to false, inherit the caller workspace's value when an agent
creates one without an override, and accept explicit true or false.
The daemon derives agent discovery from workspace ownership. Default workspace and agent lists,
including History requests, exclude background work; `filter.includeBackground` includes it.
Exact-ID operations bypass discovery filtering. The app replicates both kinds and filters only
its sidebar with **Show background**, so direct navigation and restored tabs remain available.
Sequenced directories keep complete canonical collections. A filtered read must never replace
those collections with its subset. Inclusive and default observations have distinct cursor
scopes, so changing scope requires a fresh snapshot rather than reusing an incomplete cache.
Clients gate creation and inclusive discovery on `features.backgroundWorkspaces`.
### Private daemon helpers
Branch-name and commit-message generators still use private disposable internal runtimes.
`AgentManager` does not persist them or their provider sessions, excludes them from public
discovery and plugin hooks, and drops their timelines on archive. An archived helper retains
its exact-ID result briefly to cover completion before a waiter attaches. This internal
lifecycle is separate from public workspace visibility and is not a public creation option.
## Archive
Archive is a **soft delete**: the agent record stays on disk with `archivedAt` set, the runtime is closed, and the agent disappears from active lists. Archive is **global** — it lives on the server and propagates to every connected client.
`create_agent_request` can opt an agent into `autoArchive`. In that mode the daemon archives the agent after the first terminal turn event (`turn_completed`, `turn_failed`, or `turn_canceled`). If the same request created a Paseo worktree through its `worktree` field, auto-archive archives that worktree too, which removes the agent records inside the worktree.
Archive sets `archivedAt`, invokes the provider's native archive hook, and cascades to managed
children.
`create_agent_request` can opt an agent into `autoArchive`. In that mode the daemon archives the agent after the first terminal turn event (`turn_completed`, `turn_failed`, or `turn_canceled`). When the agent owns an isolated workspace, auto-archive archives that workspace too; the managed worktree is removed when its final workspace reference is gone.
Archiving runs through `AgentManager.archiveAgent` (`packages/server/src/server/agent/agent-manager.ts`):
@@ -35,10 +125,45 @@ Archiving runs through `AgentManager.archiveAgent` (`packages/server/src/server/
2. Set `archivedAt` and normalize `lastStatus` away from `running`/`initializing`
3. Notify subscribers
4. Close the runtime (kills the process if still running)
5.**Cascade-archive children** — any agent whose `paseo.parent-agent-id` label matches the archived agent gets archived too, recursively
5.**Resolve children** — detach cross-workspace and open-tab children; cascade-archive the rest recursively
Cascade is what keeps subagent fleets from outliving their orchestrator.
Workspace archive is a separate lifecycle. Archiving or removing a worktree can close a surviving
agent record without setting the agent's `archivedAt`, while its `workspaceId` still points at the
archived workspace. History navigation must not infer workspace lifecycle from `agent.archivedAt`
or mutate either lifecycle. The workspace route asks the daemon for authoritative recovery state;
only the route's explicit Unarchive or Restore action changes the archived workspace.
History navigation opens the selected agent without changing either archive state. Workspace
**Restore** recovers only the workspace; the selected archived agent stays open with its callout.
The agent's **Unarchive** runs the provider's native unarchive hook before interactive resume and
history hydration. Other archived agents stay archived.
Opening an agent is a navigation choice, independent of whether its details are cached. The
layout retains that choice across reload while the panel fetches the agent from the daemon.
Once the daemon reports the agent active, its tab follows normal archive propagation again.
An empty active list cannot cancel an explicit History selection. Agent-detail loading does
not own selection or release the explicit open.
Persisted resume, native restore, and both live and stored-only archive enter the same per-agent
lifecycle queue. Resume chooses its history or interactive purpose from the durable record after
entering that queue. Shutdown must finish before the manager releases runtime ownership; a failed
close retains the runtime for cleanup and blocks replacement through that close operation.
Authoritative timeline catch-up can use a runtime-only `history` resume purpose. The retained
history session contains only the read results and must release its provider process once replay
finishes. Codex reads the persisted thread and child histories on a temporary app-server and never
loads, resumes, or unarchives a native thread, including legacy records whose native archive failed.
ACP providers spawn the CLI, replay the session through `session/load`, and terminate the process
before returning. Interactive resume remains responsible for repairing a provider session archived
outside Paseo while its Paseo agent is active.
Provider session connection owns every process it spawns until the session is registered with
`AgentManager`. If initialization, persisted-session resume, or initial history hydration fails,
`connect()` must dispose that process before rethrowing; the manager cannot clean up a session it never
received.
## Tabs vs archive
These are two distinct concepts that used to be conflated:
@@ -50,25 +175,69 @@ These are two distinct concepts that used to be conflated:
Closing a tab on a **root agent** still archives — the tab is the agent's home, so closing it means "I'm done with this agent." A confirm dialog protects against archiving a running agent by accident.
Closing a tab on a **subagent** (any agent with `parentAgentId`) is **layout-only**. The agent stays unarchived and stays in its parent's track. The user can re-open the tab from the track at any time. This is implemented in `handleCloseAgentTab` (`packages/app/src/screens/workspace/workspace-screen.tsx`).
Closing a tab on a **subagent** (any agent with `parentAgentId`) is **layout-only**. The app clears the current client's open-tab label before removing the tab. Another client's open tab remains protected. The agent stays unarchived and stays in its parent's track, so a later parent archive cascades to it when no client still has it open. The user can re-open the tab from the track at any time. Single and bulk tab close apply the same policy.
The asymmetry is intentional: a subagent's home is the parent's track, not the tab. Tabs are ephemeral viewing slots; the track is the persistent record of the parent's children.
The asymmetry is intentional: a subagent's persistent relationship lives in the parent's track. Same-workspace subagents are not auto-opened as tabs; the user opens one from that track when needed. A cross-workspace subagent is also auto-opened as a tab in its own workspace so opening that workspace does not appear empty. It remains in the parent's track until it is actually detached.
## Workspace activity
Agent lifecycle status stays literal: a parent agent is `idle` when its own turn is idle, even if a child is running.
Workspace status is an aggregate activity signal. Root agents contribute their normal state bucket to their own workspace. Running subagents contribute `running` to their root parent's workspace, not to the subagent's current `cwd` or worktree. Non-running subagent attention, permission, and error states stay in the parent's subagents track and do not escalate the workspace bucket.
Workspace status is an aggregate activity signal computed **per `workspaceId`**. Ownership is never derived from `cwd` — many workspaces may share one directory, and same-`cwd` siblings do not clump under one status. Root agents and cross-workspace subagents contribute their normal state bucket to their own workspace. Same-workspace descendants contribute `running` to the nearest ancestor in that workspace; their non-running attention, permission, and error states stay in the parent's subagents track. This makes a cross-workspace subagent behave like a detached agent for workspace visibility and status without removing its parent relationship.
Running provider-native subagents contribute `running` to the workspace owned by their parent agent. Their completed, failed, and canceled states stay in the parent's subagents track.
A finished workspace can be marked unread after it has been reviewed. The daemon restores
`finished` attention on its newest eligible workspace-root agent without sending a new completion
notification. Opening the workspace clears that attention through the normal focus flow.
Attention is set by the agent finishing or failing and cleared by the client's
`workspace.clear_attention`, which fires when the user reads the chat. Loading an agent's runtime is
neither, so resuming carries the stored attention and the stored last-activity time through
untouched. Forging either makes a background resume look like the user read a workspace and like the
agent worked in it just now, which rewrites the sidebar timestamp permanently — persisted
`updatedAt` is what workspace `statusEnteredAt` is re-derived from on the next daemon start.
## The subagents track
The collapsible track above the composer in an agent's pane (`packages/app/src/subagents/track.tsx`). Membership rule (`packages/app/src/subagents/select.ts`):
The track is a pill at the foot of an agent's pane (`packages/app/src/subagents/track.tsx`): a count you can read at a glance, and a panel behind it — a popover on wide screens, a sheet on compact ones — holding the rows. It floats over the transcript rather than sitting in a band above the composer, so the timeline scrolls underneath it; `packages/app/src/panels/agent-tracks.tsx` owns that placement, and the pill frame is shared with the task list in `packages/app/src/composer/tracks.tsx`.
The rows combine two kinds of children:
- **Paseo subagents** are full managed agents. Their membership rule (`packages/app/src/subagents/select.ts`) is:
```
parentAgentId === thisAgent.id AND !archivedAt
```
Archived subagents disappear from the track, by design. To remove a subagent from the track without closing its tab, use the **archive button (X)** on the row — it opens a confirm dialog and archives the subagent on confirm. That same archive shows the subagent leave the track on every connected client.
- **Provider subagents** are child executions owned by Claude, Codex, or OpenCode. They are not inserted into `AgentManager` as managed agents. Providers emit a separate descriptor and timeline stream through `agent.provider_subagents.*`; the client keeps that state outside the normal agent store and merges only the presentation rows into the track. A descriptor's optional `parentSubagentId` identifies its direct provider-subagent parent; an absent value identifies a direct child of the managed agent.
Clicking either kind opens a workspace tab. On compact layouts it opens in the [workspace sheet](explorer-sidebar.md#workspace-sheet) instead; the sheet hides the composer, and a Paseo subagent's sheet offers **Open as tab** to get it back. A Paseo subagent tab is a normal interactive agent pane. A provider subagent tab is a read-only timeline pane with no composer, archive, detach, rewind, or fork actions. It shows its own direct children in a subagents track. Both panes use `AgentStreamView`, so message, reasoning, tool-call, and layout rendering stay identical.
Provider timelines use the same structural timeline item format but deliberately have a separate lifecycle and transport. A provider thread/session identifier is not a Paseo agent identifier, and closing its tab is always layout-only.
Provider descriptors may include one compact subtitle. The provider owns its contents and formatting; clients display and truncate it without interpreting provider-specific model, thinking, or usage fields.
### Claude provider subagents: the task protocol
Claude Code announces subagent lifecycle on the SDK stream (`task_started` / `task_updated` / `task_notification` / `task_progress`), and Paseo reads those announcements rather than reconstructing them from sidechain frames. The live source (`subagents/live-source.ts`) and the replay source (`subagents/replay-source.ts`) both translate into one observation vocabulary (`subagents/observation.ts`), so a fact is derived once for both paths instead of once per path. Gotchas that are not obvious from the SDK types:
- **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 `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.
Archived Paseo subagents disappear from the track, by design. To remove one from the track without closing its tab, use the **archive button** on the row — it opens a confirm dialog and archives the subagent on confirm. Provider-owned rows have no individual Paseo lifecycle controls.
The **Archive finished** row at the foot of the panel covers every finished row. It archives idle or errored managed Paseo subagents one at a time, and hides completed, failed, or canceled provider-owned rows in the current app session. Native sessions and timelines are untouched. Running and initializing children remain in the track. If a hidden provider child starts running again, the app brings it back to the track.
To keep the agent alive but remove it from the parent's track, use **detach**. The daemon clears the relationship lifecycle labels, emits the normal agent update, and every client reclassifies the agent from subagent to root/sibling from that updated snapshot.
## Why this shape
@@ -77,6 +246,7 @@ The decision was to **decouple "close tab" from "archive" only for subagents**,
- **Closing a tab on a root agent still archives** — preserves the existing UX users are trained on
- **Closing a tab on a subagent is layout-only** — fixes the lossy "click to read, close to dismiss view, lose the row" flow
- **Archive button on track rows** — gives subagents an explicit lifecycle gesture in their home surface
- **Detach button on track rows** — lets a subagent continue independently without killing its work
- **Cascade archive on parent** — keeps subagents from leaking when the parent is archived
We considered universal decoupling (no tab close ever archives, archive is always explicit) but rejected it: it changes a behavior root-agent users rely on.
@@ -85,7 +255,7 @@ We considered universal decoupling (no tab close ever archives, archive is alway
### Subagent accumulation under long-lived parents
A parent that spawns many subagents will see the track grow. There's no automatic cleanup for completed subagents — the user prunes via the archive button on each row. A bulk gesture (e.g. "archive all idle children") could land later if this becomes a real problem.
A parent that spawns many subagents will see the panel's list grow; the pill only counts them. Managed Paseo subagents can be archived individually or with **Archive finished**. That action hides finished provider-owned rows locally; this presentation state resets when the app restarts.
### Cross-client tab dismissal
@@ -97,13 +267,16 @@ Closing a subagent's tab on one client doesn't affect other clients' layouts. Th
`{cwd-with-dashes}` is derived from the agent's filesystem `cwd`. It is not the workspace id; agent storage stays cwd-keyed while workspace identity is the opaque workspace id.
Each agent is a single JSON file. Fields relevant to this doc:
How assistant text gets from a provider to the screen, and why it is paced on the way. Read this before changing `packages/server/src/server/agent/agent-stream-coalescer.ts`, the reducer queue in `packages/app/src/timeline/session-stream-reducers.ts`, or the reveal in `packages/app/src/hooks/use-revealed-text.ts`.
For terminal output, which is a separate pipeline with separate budgets, see [terminal-performance.md](terminal-performance.md).
Every provider delivers incremental text, so there is no provider that needs special handling: Claude via `includePartialMessages`, Codex via `agent_message_delta`, ACP agents via `agent_message_chunk`, Pi and OMP via `text_delta`.
## Why the reveal is paced
Arrival is lumpy and there is no fixing that at the source. A 60ms coalescing window carries however many characters the model produced in those 60ms, which swings by an order of magnitude within a single turn. Painting each delta as it lands makes the size of those lumps visible, and that is what reads as jagged.
So arrival sets a _target_ and the reveal rate is derived from the backlog instead. A burst makes the text catch up faster; it does not make the text jump. Shrinking the coalescing window does not fix this — it makes the lumps smaller and more frequent, at the cost of message rate on a daemon loop that already contends with terminal frames and per-message relay encryption.
## Invariants
- **The coalescer is leading + trailing.** The first delta after an idle window flushes synchronously; only the rest of the burst waits for the trailing timer. Reverting to trailing-only adds a full window to the first character of every turn. Same shape and the same reason as `TerminalOutputCoalescer`.
- **The leading flush adds a canonical row, and that is fine.** A burst's first chunk lands as its own timeline row. `mergeAssistantChunks` / `mergeReasoningChunks` in `timeline-projection.ts` join contiguous same-turn rows, and clients read the projected timeline, so history is unaffected. Tests that assert on raw rows have to account for the extra row; tests that assert on what a client sees do not.
- **The store holds the full text; only the rendered slice is paced.** Copy, selection, the chat outline, and scroll geometry all read the same string the user can see. Pacing the store instead would leave the bottom anchor chasing a content height that is ahead of the reveal.
- **Markdown blocks belong to presentation, and every assistant message is a block group.**
`agent-stream/presentation.ts` splits a message into one display row per Markdown block, the same
way whether it arrived streaming or as fetched history. Splitting in the reducer instead would
discard paragraph separators and expose fragments to plugin callbacks, which need the whole source
text. Live-head work stays cheap by parsing only the growing last block during append, retaining
completed blocks by object identity, and caching the split per source item so a tail change does
not re-split history. History was once left whole to keep cross-block Markdown context; that gave
one message two shapes depending on how it arrived, and message-addressed features only worked on
one of them.
- **A row id is never a message id.** Block rows are `${messageId}:block:${n}` with
`blockGroupId = messageId`, including single-block messages, so nothing can come to rely on the two
being equal. `getStreamItemMessageId` in `presentation.ts` is the only way to go from a row to its
message, and web rows carry it as `data-message-id` alongside `data-history-row-id`. Anything
addressing a message — chat find, scroll-to-message, history reveal, the find expansion that lifts
the render cap — uses the message id and must expect several rows to answer to it. Row ids stay for
React keys, virtualizer measurement, scroll anchors, and per-row caches like assistant image
occurrence keys.
- **First sight of a text is revealed whole.** Only growth is paced. This is what makes history hydration, timeline replay, a virtualized row remounting on scroll, and an already-finished message all render complete on first paint without a special case for each.
- **Leaving `phase: "streaming"` snaps the reveal.** A completed turn must never be left holding characters. `layoutStream` sets the phase, so anything outside the live head with an active turn is already complete.
- **The reducer queue commits on a frame, with a timer as the ceiling.** A frame callback never fires in a hidden tab, so a timer races it and wins when nothing is painting — the store has to keep advancing either way.
- **A history row re-renders only when its item or layout item identity changes.** The inverted
FlatList hands every mounted cell a new `index` and `ref` whenever a row is prepended, so without a
memo boundary each coalesced tick re-rendered every mounted row (about 50 on a phone, 100–250 ms of
JS per tick). `layoutStream` keeps a layout item's identity when nothing about it changed,
`useRevisedHistoryRows` hands a fresh item identity to rows whose tool-call group, expanded state,
or breakpoint changed, and `HistoryStreamRow` memoizes on both. Every viewport runs its history
through that hook; the web viewport once skipped it and history hosts of a live tool group went
stale. A new field on `StreamLayoutItem` must be added to `areLayoutItemsEquivalent`, or sharing
silently stops.
## Measuring
- **Smoothness (user-perceived):** `packages/app/e2e/browser/agent-stream-smoothness.spec.ts`, gated behind `PASEO_AGENT_STREAM_PERF_E2E=1`. Drives the mock provider's `bursty-stream` model and reports coefficient of variation of characters painted per frame (smoothness) plus p95 gap between visible updates (stalls). Both numbers are needed: a stalled stream is perfectly smooth.
- **Reproducing bursty arrival:** the `bursty-stream` model in `mock-load-test-agent.ts` emits uneven runs of tokens separated by idle gaps. Burst sizes come from a seeded generator, so a run repeats exactly.
- **Rate policy in isolation:** `computeRevealStep` in `packages/app/src/agent-stream/text-reveal.ts` is pure; `text-reveal.test.ts` covers convergence and burst flattening without a renderer.
Healthy numbers (2026-08, Expo web against a local dev daemon, real Claude Haiku agent, ~8.5s samples during active streaming). Setting `TEXT_REVEAL_HORIZON_MS` to 0 makes the reveal paint on arrival, which is how the baseline column was taken:
| gap between visible updates, p50 | 317ms | 17ms |
| gap between visible updates, p95 | 383ms | 17ms |
Total characters painted is roughly the same either way — the reveal changes when they land, not how many arrive.
Measure the **total** length across every `assistant-message` element, not the last one. A turn emits many assistant messages, so the tail element keeps changing identity and its length is not monotonic; sampling only the tail reads those handovers as resets and reports almost no growth. `sampleStreamFrames` does this correctly.
@@ -13,6 +13,52 @@ EAS profiles: `development`, `production`, and `production-apk` in `packages/app
`development` uses Android `debug`.
## Version codes
`packages/app/native-release-version.js` is the single definition of native and F-Droid version-code math. Do not re-derive these numbers anywhere else — a drifted copy produces changelog files that match no published APK, and nothing fails loudly.
The base version code comes from the package version:
```text
major * 1_000_000 + minor * 1_000 + patch
```
Prerelease metadata is ignored, so `0.1.102-beta.1` and `0.1.102` both produce `1102`. The same value is used as the iOS `buildNumber` because `packages/app/eas.json` uses EAS's local app version source. Do not re-enable EAS remote version counters or Android `autoIncrement`; F-Droid and other source-based builders need the native build number to be visible in the repo.
The formula reserves three digits each for minor and patch. If either reaches `1000`, change the formula before cutting that release.
## Prerequisites (local dev)
Local Android builds run on macOS (or Linux) and need the Android toolchain, pinned in `.tool-versions` (`java 21`, `android-sdk 21.0`) and wired up by `.mise.toml` (which derives `ANDROID_HOME` and the command-line tool paths from the `android-sdk` entry). With [mise](https://mise.jdx.dev):
```bash
mise install # java 21 + android-sdk 21.0 command-line tools
```
> **Pin a real `android-sdk` version, not `latest`.** The mise `android-sdk` plugin's `latest` resolved to the ancient `1.0` bundle, whose `sdkmanager` (3.6.0) predates the `emulator` package and fails with `Failed to find package emulator`. `21.0` ships a current `sdkmanager`. If you bump it, update only the version in `.tool-versions`; `.mise.toml` derives its paths from that tool entry.
`mise install` only lays down the command-line tools. Install the rest and create an emulator. On Apple Silicon:
## Running on an emulator against a worktree daemon
`npm run android` builds and installs the dev client, but two connections have to reach your Mac from inside the emulator — Metro (the JS bundle) and the Paseo daemon — and **the emulator does not share the host's loopback**: `localhost` inside the emulator is the emulator itself. Reach the host at `10.0.2.2` (the standard AVD's host alias) for both:
- **`REACT_NATIVE_PACKAGER_HOSTNAME=10.0.2.2`** — without it, Expo bakes your Mac's LAN IP into the dev client's Metro URL, which the emulator can't route to, and the app dies with `Failed to connect to /<lan-ip>:8081` before any JS loads.
- **`EXPO_PUBLIC_LOCAL_DAEMON=10.0.2.2:<port>`** — the client's daemon endpoint (`packages/app/src/runtime/host-runtime.ts`); when unset it defaults to `localhost:6767`, the production daemon. Use `$PASEO_SERVICE_DAEMON_PORT` for a worktree daemon running as a Paseo service, or `6768` for a standalone `npm run dev:server`. It is inlined into the JS bundle at Metro bundle time, so set it on the build command and clear the Metro cache (`npx expo start -c`) if a change doesn't take.
This is the Android counterpart of the iOS local-simulator flow in [development.md](development.md): on iOS the simulator shares the Mac's loopback so `localhost:<port>` works directly; on Android you need `10.0.2.2` or `adb reverse`.
## Inverted timeline selection
Android focus and selection visibility requests must not reposition inverted timelines. The
`modules/paseo-scroll` package keeps React Native's scroll manager interface and returns zero for
child-reveal scroll calculations when the vertical scale is inverted. Dragging and explicit scroll
commands still work; non-inverted scroll views keep Android's default behavior.
Register this package before React Native's core package through its Expo config plugin. Normal
Android builds use the prebuilt `react-android` library, so patching Java under `node_modules` does
not change the shipped scroll view. Keep this behavior in the app's compiled native module.
## F-Droid / source-only Android builds
F-Droid builds should set `PASEO_FDROID_BUILD=1` when running Expo prebuild:
The flag must be present for both prebuild and Gradle because Gradle starts Metro for the release bundle. Keep the source build serial and daemon-free as shown above: compiling every Expo module can exhaust memory when Gradle workers run in parallel. The profile enables source-built Expo modules, excludes the proprietary camera, Firebase notification, and Expo development-client native modules, disables Gradle dependency metadata, and substitutes JavaScript stubs for camera and notifications. The resulting app supports direct and pasted-link pairing but not QR scanning or push notifications.
For a single-ABI APK, pass React Native's architecture property to Gradle:
Supported values are `armeabi-v7a`, `arm64-v8a`, `x86`, and `x86_64`. The F-Droid profile filters native libraries to that ABI and changes the APK version code to `baseVersionCode * 10 + abiSuffix`, where the suffixes are ordered `1` through `4` in that same sequence. F-Droid metadata should use four build blocks with `VercodeOperation` entries `10 * %c + 1` through `10 * %c + 4` and pass the matching `reactNativeArchitectures` value in each build command. Builds without a single architecture keep the base version code.
Keep the excluded npm packages installed. Normal builds use them, while the F-Droid profile removes only their Android native modules and config plugins. Paseo always applies `expo-gradle-jvmargs` with `-Xmx4096m` and `-XX:MaxMetaspaceSize=1024m` so local Expo prebuilds have enough Gradle heap whether they use precompiled AARs or source-built Expo modules.
The EAS `production-apk` profile uses the large Android resource class. Release builds compile the native ABIs and run Hermes bundling in the same Gradle invocation; the default worker can exhaust its remaining memory and kill Hermes with exit code 137 even when Gradle's own heap is correctly sized.
### F-Droid store metadata
F-Droid reads the store listing from `fastlane/metadata/android/<locale>/`**at the repo root**. This location provides the best compatibility with the F-Droid release process.
Locale directories generally match `packages/app/src/i18n/locales.ts`, but note that `en` becomes `en-US`.
F-Droid changelogs are generated from `CHANGELOG.md`. Run `npm run fdroid:changelogs`; `npm run fdroid:changelogs:check` verifies without writing. It is wired into the npm `version` lifecycle, so a release picks it up automatically and `git add -A` stages the result.
One changelog must be generated per-ABI-split, so each version will create **four** identical version-coded entries. F-Droid caps changelogs at 500 characters, so the generator strips some content and adds a link to the full notes.
Stable sync fails loudly if `CHANGELOG.md` has no entry for the version being cut. That is intentional — the release checklist requires the entry to be committed first, so an abort here means the checklist was skipped.
Because the generator runs off the version in `package.json`, it must run **before** the tag is created: fdroidserver only reads metadata from the tag it builds, so the file for version N has to exist in the commit N points at.
Beta releases are an explicit no-op: they do not create or rewrite F-Droid changelog files. Stable releases and promotions generate the four ABI entries from their final changelog.
### React version lockstep
Keep `react` and `react-dom` pinned to the React version embedded by the current `react-native` release. React Native `0.81.x` embeds `react-native-renderer``19.1.0`, so `packages/app` must use React `19.1.0`. Bumping React to a newer patch can build successfully but crash at JS startup on Android with `Incompatible React versions`, leaving the app on the native splash screen.
### `packages/protocol` — Wire schemas and shared protocol types
@@ -83,47 +103,92 @@ facade. App and CLI may import the low-level driver from
`@getpaseo/client/internal/daemon-client` during migration, while new SDK-shaped
code imports from `@getpaseo/client`.
`PaseoApi` is the capability-only boundary over workspaces, agents, terminals, providers, and config.
`PaseoClient` adds connection lifecycle. App plugin surfaces borrow an API over their selected
host's client; plugin subprocesses use the same facade over a host-owned IPC transport. Protocol capability ownership and subscription lifetimes follow
-`HostRuntimeController` manages saved host connections, reconnection, and per-host runtime state
- Expo Router navigation (`/h/[serverId]/workspace/[workspaceId]`, `/h/[serverId]/agent/[agentId]`, etc.). The `workspaceId` URL segment is an opaque workspace id, not a directly meaningful filesystem path.
-`HostRuntimeController` manages saved host connections, reconnection, and per-host runtime state. Direct TCP and relay connections use the ordinary client transport; desktop socket, pipe, and SSH connections cross one Electron-owned transport boundary. SSH only tunnels to an already-running daemon.
-`runtime/replica-cache` is typed storage behind the directory and timeline owners. It never observes or mutates `SessionStore`.
-`runtime/directory-sync` owns directory cache selection and network reconciliation. On demand it paints accepted rows for one host, then passes the persisted per-entity cursor through `project.list`, `fetch_workspaces`, and `fetch_agents`; the daemon returns each entity's latest projection when its sequence is newer, plus tombstones.
-`workspace-labels` owns one sequenced catalog replica per connected host, the deterministic cross-host projection that surfaces spanning hosts use (the filter page, the manager), and the per-host resolution a workspace row's chips use. Two hosts may give one name different colors, so a row resolves against its own host's catalog and a merged answer would be wrong there. Catalogs never synchronize between hosts; assignment creates a missing definition only on the target host. On the daemon, catalog and assignment rewrites share a journaled commit boundary. Startup recovery completes that commit before workspace or catalog publication.
-`SessionContext` wraps the daemon client for the active session
- Composer UI and submit/draft behavior live in `packages/app/src/composer/`; screens and panels should integrate it from there instead of dropping composer internals into `components/`, `hooks/`, or `screens/workspace/`
- Timeline reducers in `timeline/session-stream-reducers.ts` handle compaction, gap detection, sequence-based deduplication
- Timeline sync correctness is documented in [docs/timeline-sync.md](timeline-sync.md): live streams are for immediacy, `fetch_agent_timeline_request` is authoritative, and catch-up is paged but complete.
- Voice features: dictation (STT) and voice agent (realtime)
Consumers request directory or timeline data without choosing memory, cache, or network. The owner
publishes an accepted cache hit and then reconciles it over the existing network path. A miss or an
invalid row uses that same path. Offline demand still publishes an accepted cache hit and defers
network reconciliation. Cache loading is demand-driven: opening a chat reads its agent row and
focused timeline row, plus the workspace and project rows needed by the route; opening a directory
reads directory rows for that host and establishes its live subscription when connected. Accepted
cached rows satisfy the same consumer-readiness projection as network rows. Host registry startup and
host connection do not create directory demand or install replicas. The directory owner retains
declared surface demand and re-establishes network reconciliation after reconnect; React does not
track connection generations. Late cache reads cannot replace state already advanced by live or
authoritative network data. Owners explicitly persist accepted commits; directory rows and their
checkpoint share one storage transaction. See
[data-model.md](data-model.md#replica-row-store)
for the storage shape and [timeline-sync.md](timeline-sync.md#client-replica-lifetime) for timeline
resume behavior.
The three directory entity types have independent monotonic sequences and share one daemon
generation. The daemon retains only the latest projection per entity and bounded tombstones, not an
event log. A missing, expired, or previous-generation cursor receives a full snapshot. Projects are
independent records; a project with no workspaces does not need a workspace placeholder.
Workspace label definitions use a separate, explicitly subscribed sequence. The list request both
fetches and grants live updates for that session. A current cursor receives an empty correlated
catch-up response when nothing changed; idle sessions and unsubscribed sessions receive no label
traffic. Workspace assignments stay on the workspace directory sequence.
### `packages/cli` — Command-line client
Commander.js CLI with Docker-style commands. Common agent operations are also exposed at the top level (e.g. `paseo ls`, `paseo run`).
-Relay server is zero-knowledge — it routes encrypted bytes, cannot read content
- Curve25519 establishes the relay-session secret; NaCl `box` protects each payload with XSalsa20-Poly1305
-The relay is zero-knowledge — it routes encrypted bytes and cannot read content
- Client and daemon channels with identical API (`createClientChannel`, `createDaemonChannel`)
- Pairing via QR code transfers the daemon's public key to the client
- New homes keep relay disabled until pairing consent. `DaemonConfigStore` persists the desired state, while the relay runtime starts or stops the outbound transport live; pairing reads that current state instead of a startup snapshot.
- Optional E2EE capability negotiation preserves application frame kind: text plaintext uses base64 ciphertext text frames, while binary plaintext uses raw ciphertext binary frames; mixed-version peers remain base64-only
- Self-hosted relays opt into TLS with `daemon.relay.useTls` or `PASEO_RELAY_USE_TLS=true`; the public (client-facing) TLS setting can be overridden independently via `daemon.relay.publicUseTls` or `PASEO_RELAY_PUBLIC_USE_TLS`
The production relay server lives in [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay). It is a distributed Elixir service. The Cloudflare relay implementation in this monorepo is retained as legacy code and is not deployed.
See [SECURITY.md](../SECURITY.md) for the full threat model.
### Paseo Hub
The optional Hub relationship is daemon-outbound and does not use the relay. Its connection,
authorization, ownership, persistence, and lifecycle contract is documented in [hub.md](hub.md).
### `packages/desktop` — Desktop app (Electron)
Electron wrapper for macOS, Linux, and Windows.
@@ -132,11 +197,33 @@ Electron wrapper for macOS, Linux, and Windows.
- Native file access for workspace integration
- Same WebSocket client as mobile app
The desktop does not manage agent skills. It retains one compatibility reader for the old
`skill-selection.json`, imports that preference into its managed local daemon, then deletes the old
file after the daemon confirms persistence.
**Multi-window (hybrid land-on model).**`createWindow()` in `main.ts` is reusable: `⌘⇧N`/File→New Window, relaunching the app (`second-instance`), and the sidebar "Open in new window" action each open a fresh `BrowserWindow`. Every window shows the full sidebar — there is no per-window project ownership or filtering. "Land on a project" is delivered by a per-`webContents``PendingOpenProjectStore`: each window pulls its own pending project path on mount (`paseo:get-pending-open-project`) and runs the normal open-project flow, identical to a CLI `paseo <path>` launch.
> **Window-state v1 limitation:** only the _first_ window of a session restores and persists saved geometry (size/position/maximized). Windows opened via ⌘⇧N / second-instance / "Open in new window" open at the default size, OS-cascaded, and do not persist — this avoids every window stacking on the same restored bounds and fighting over the single window-state store. Lifting this needs per-window state keys.
>
> **In-app browser panes are not yet per-window.** The active-browser id (`features/browser-webviews.ts`) and the webview registration queue (`pendingBrowserWebviewIds` in `main.ts`) are process-global. With browser panes open in two windows, a menu Reload can target the other window's webview, and near-simultaneous webview attach across windows can register under the wrong browser id. Multi-window v1 ships windows; making the browser-webview subsystem window-scoped is a follow-up.
> **In-app browser profile.** Every browser guest uses one stable persistent Electron session, so cookies, authentication, cache, and site storage are shared across tabs, workspaces, and desktop windows and survive tab or app closure. Browser identity is independent of that storage partition: after every `did-attach`, the renderer explicitly registers its browser id, workspace id, and current guest `WebContents` id, and main accepts the registration only when that guest belongs to the calling renderer and the shared profile. Registration is intentionally repeated because reparenting a retained `<webview>` can replace its guest without replacing the DOM element. Settings > General > Clear browser data is the sole profile-deletion path; it clears the shared session and reloads live guests without deleting saved tabs or URLs.
>
> **In-app browser window opens.** Ordinary link opens, including Shift-clicked links, become Paseo workspace tabs. Script-created opens with popup features or a named window target and POST-backed opens remain secured Electron child windows in the shared browser profile, preserving `window.opener`, `postMessage`, named-window reuse, request bodies, and `window.close()` for OAuth, payment, and similar popup protocols. Unsupported URL schemes are denied before either path.
>
> **In-app browser ownership.** Each registered guest records its owning host window. The active browser is keyed by `(host window, workspace)`, and application-menu Reload / Force Reload resolve only within the window Electron supplies to the menu callback. A non-null active update must name a browser owned by that host; a null update clears only that host/workspace. Browser automation continues to target explicit browser ids returned by `browser_new_tab` or `browser_list_tabs`.
>
> **Browser keyboard boundary.** Guest pages receive renderer-published shortcuts first. `Cmd/Ctrl+L` and `Cmd/Ctrl+R` are explicit guest-shell reservations; ordinary Paseo shortcuts run only after the page declines them. The sandboxed guest preload runs in every frame so focused iframes use the same boundary, while Node integration remains disabled. Human guest input disables Electron's menu fallback for plain keys. Agent-generated keys use guest `sendInputEvent` with `skipIfUnhandled`, so an unhandled Enter stops at the guest instead of reaching the host composer. Main selects the preload; it exposes no APIs to guest pages.
`-- guest does not handle ----> stop; never redispatch to the host window
```
### `packages/website` — Marketing site
@@ -161,13 +248,28 @@ Server → Client: status message with payload { status: "server_info",
serverId, hostname, version, capabilities?, features }
```
There is no dedicated welcome message; the server emits a `status` session message after accepting the hello, then begins streaming. The session stores client capabilities from the hello and rehydrates them on reconnect, so the wire boundary can ask one question: `session.supports(...)`.
There is no dedicated welcome message; the server emits one initial `status.server_info` after
accepting hello. Modern clients negotiate `owned_subscriptions` against
`features.ownedSubscriptions`. Connecting or attaching a local listener creates no application-data
demand. A list is a snapshot; adding `subscribe: {}` creates an independent server ID. The client
correlates the initial reply by request ID, then routes updates by subscription ID.
A logical Session can contain several physical sockets. Each socket owns its capabilities,
subscriptions and ongoing operations; the final JSON/binary send boundary requires that source's
request or subscription provenance. Domain modules own filtering, bootstrap and resource teardown.
The shared ownership boundary only tracks source and lifetime. Releasing a subscription acknowledges
its teardown; reconnect creates new server IDs for surviving client handles. See
[protocol compatibility](protocol-compatibility.md) for the legacy boundary.
**Top-level WS envelopes** are `hello`, `recording_state`, `ping`/`pong`, and `session` (which wraps the rich union of session messages).
Client liveness checks use the top-level JSON `ping`/`pong` envelope, not a session RPC and not RFC6455 protocol ping. The app runs through browser and React Native WebSocket APIs, which do not expose protocol ping, so this envelope is the portable way to test the direct or relay data path. Session RPC timeouts are operation failures and must not be treated as proof that the socket is dead.
Client liveness checks use the top-level JSON `ping`/`pong` envelope, not a session RPC or RFC6455 control ping. Current clients ping every 10 seconds, beginning one interval after connecting. The first ping claims an application-ownership lease for that physical socket, all later inbound activity renews it, and the daemon forcibly terminates the socket if the lease expires. A legacy or raw socket that never sends an application ping never enters this lease and is not closed for omitting one. Session RPC timeouts are operation failures, not proof that the socket is dead. A subscription bootstrap timeout closes its source to clear any unknown server-owned registration; the failed handle is released before other surviving handles reconnect.
New session RPCs use dotted names with `.request` and `.response` suffixes, such as `checkout.github.set_auto_merge.request` and `checkout.github.set_auto_merge.response`. See [rpc-namespacing.md](rpc-namespacing.md) for the convention and migration rules for older flat RPC names.
Every physical send path enforces an 8 MiB outbound high-water mark, including JSON broadcasts, binary terminal frames, and the encrypted relay adapter's asynchronous queue. This sits above the terminal stream's 4 MiB soft backpressure threshold, leaving room for snapshot catch-up before the hard cutoff. JSON is serialized once per broadcast after sockets already at the limit are removed, then its exact byte length is checked for every remaining socket. A frame that would cross the limit is not sent; that physical socket is forcibly terminated without disturbing other sockets attached to the same logical session. Multiple tabs and simultaneous direct and relay paths may legitimately share a client id.
Client session RPC waits default to 60s so slow relay or mobile networks do not turn a live but delayed daemon response into a false operation failure. Keep connect timeouts, app-level grace windows, explicit diagnostic latency probes, liveness ping timers, and genuinely long-running RPCs separate from this default.
New session RPCs use dotted names with `.request` and `.response` suffixes, such as `checkout.forge.set_auto_merge.request` and `checkout.forge.set_auto_merge.response`. See [rpc-namespacing.md](rpc-namespacing.md) for the convention and migration rules for older flat RPC names.
**Notable session message types:**
@@ -177,10 +279,24 @@ New session RPCs use dotted names with `.request` and `.response` suffixes, such
- Request/response pairs for fetch, list, create, etc., correlated by `requestId`; failures use `rpc_error`
`directory_suggestions_request` is one daemon-owned filesystem search capability. The daemon
configures the same `searchDirectoryEntries` engine with a root, output format, path-query policy,
entry-kind filters, match mode, blank-query behavior, and hidden-directory traversal policy. A
request without `cwd` searches the host home for absolute project paths; a request with `cwd`
searches that workspace and returns relative entries. Clients may prepend their small host-scoped
recent-project list for bare queries, but must not parse filesystem query syntax or re-filter a
correlated daemon response. The legacy `directories` response field remains a projection of the
typed `entries` list.
**Binary frames (terminal stream protocol):**
Terminal I/O is sent as binary WebSocket frames decoded by `decodeTerminalStreamFrame` in `shared/binary-frames/terminal.ts`. The layout is:
@@ -192,12 +308,16 @@ Terminal I/O is sent as binary WebSocket frames decoded by `decodeTerminalStream
Terminal PTY size is last-interacting-client-wins. A client claims the PTY size only when its terminal viewport genuinely changes size or the user focuses/taps the terminal. Passive rendering work — attaching, restoring visibility, font settling, renderer refits, or just looking at a visible terminal — must not send a resize frame. The server does not broadcast resize ownership; the resized PTY redraws through normal output, and every attached client renders that output in its own local viewport.
There is also a separate file-transfer binary frame format in the same directory, used for download/upload streams.
File downloads keep the existing `FileBegin`/`FileChunk`/`FileEnd` framing and stream 256 KiB chunks
from one stable file handle. Each transfer awaits completion of its own physical WebSocket send before
reading the next chunk; it is scoped to the requesting physical socket and does not queue unrelated
messages or transfers.
### Compatibility rules
- WebSocket schemas are append-only. Add fields, do not remove fields, and never make optional fields required.
- New wire enum values must be gated at serialization with `session.supports(CLIENT_CAPS.someCapability)`.
-`Session` stores client capabilities from the `hello` handshake and rehydrates them on reconnect, so the wire boundary can ask one question: `session.supports(...)`.
-Capabilities belong to the physical source socket. `session.supports(...)` reads the requesting or observing source at the domain projection boundary; one socket must not borrow a sibling's capabilities.
Example: adding a new enum value
@@ -208,6 +328,40 @@ Example: adding a new enum value
// 4. Gate the new emitted value: session.supports(CLIENT_CAPS.newThing) ? "new_value" : "old_value"
```
### Creation ownership
Creation is owned by the daemon across socket lifetimes. A workspace request can include
its initial agent and prompt. Workspace readiness is published before provider startup;
the app keeps its existing disk-ready navigation and observes the remaining creation.
Callbacks do not advance the workflow. Resource reservations in acknowledgement are
identities, not ready workspace records.
The creation journal (`server/creation/`) owns identity and execution for both legacy and
modern creation RPCs. Requesting progress never selects a different journal. Existing
agent receipts are imported at this boundary; message delivery receipts remain separate.
It keeps cumulative milestones and permanent IDs under an operation kind and idempotency
key. Reconnect subscribes to that key; retries
join active work or reuse committed stages. A persisted resource alone cannot prove
that a provider accepted its initial prompt. Interrupted side effects with no conclusive
receipt return an unknown outcome instead of being repeated.
Agent registration commits creation. Rejecting the first prompt is a turn error on that
agent; reporting creation failure leaves an orphaned agent and invites another Create.
Receipt replay must not resend the prompt.
`packages/client/src/creation/` owns capability selection and legacy orchestration.
Callers always pass the initial prompt to agent creation. On an older host, the client
adapts keyed creation to the legacy create/send sequence; it cannot continue that
sequence after the client process disappears. Explicitly requested IDs or receipts
that an old host cannot honor produce an unsupported error. Keep these adapters inside
the client package, as an exception to the default no-fallback feature policy.
Creation executes through the existing Session capabilities. Connection-owned delivery
controls observation only: detaching a socket or cleaning up its Session does not cancel
accepted creation. Updates require an explicit subscription and go only to that socket;
reconnect uses the shared subscription owner. Legacy consumers, including Hub, keep their
existing response contract.
## Agent lifecycle
The lifecycle states are defined in `shared/agent-lifecycle.ts`:
`ManagedAgent` is a discriminated union over those lifecycle tags. Notes:
- **AgentManager** is the source of truth for agent state and broadcasts updates to all subscribers
- Timeline is append-only with epochs (each run starts a new epoch). Storage uses sequence numbers for client-side dedup; the default fetch page is 200 items
- Timeline sequence allocation is append-only with epochs (each run starts a new epoch). The one
permitted in-place enrichment adds a provider message id to the manager-owned row for an accepted
prompt; it preserves the row's sequence, content, and timestamp. Storage uses sequence numbers for
client-side dedup; the default fetch page is 200 items.
- Timeline row `timestamp` values are canonical daemon-owned timestamps. Providers may supply original replay timestamps, but clients must not guess timestamp trust or hide time UI based on local clock heuristics.
- Events stream to connected clients in real time; correctness is backed by authoritative timeline fetches and paged-to-completion catch-up.
- Agent state persists to `$PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json` (timeline rows live alongside the record)
- Agent state persists to `$PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json`. Timeline rows are runtime memory; provider history is the durable transcript authority and resumed agents rebuild from it. That storage path is derived from `cwd`, not from workspace id.
## Right-sidebar boundary: directory-backed vs workspace-owned
Two workspaces can share the same `cwd` (e.g. a `directory` workspace and a `local_checkout` workspace on the same folder, or several workspaces opened against one checkout). Model B keeps these distinct: they share everything the directory determines, but nothing the workspace owns. The right-sidebar surfaces split cleanly along this line, and the split is enforced purely by **what each piece of state is keyed by**.
**Directory-backed (shared by same-`cwd` workspaces) — keyed by `(serverId, cwd)`, never by `workspaceId`:**
| File explorer nav/open state | `fileExplorer` map keyed `workspace:{workspaceId}` | `packages/app/src/hooks/use-file-explorer-actions.ts` |
| File explorer expanded paths | `expandedPathsByWorkspace[workspaceStateKey]` | `packages/app/src/stores/panel-store/state.ts` |
`diff-pane.tsx` is the canonical wiring site: it passes `{ serverId, cwd }` to the git queries and `{ serverId, workspaceId, cwd }` to the draft/comparison/attachment scope keys.
**Do not "fix" the sharing away.** Re-keying a directory-backed query by `workspaceId` makes same-`cwd` workspaces diverge (two windows onto the same git tree showing different diffs). Re-keying owned state (drafts, expanded paths) by `cwd` makes them leak between distinct workspaces on the same folder. The `workspaceId`-keyed builders carry a `// workspaceId is opaque; do not parse this key back into a path.` comment — the opaque-id fallback to `cwd` exists only for old payloads without a `workspaceId`, not as a content-sharing mechanism.
One deliberate non-violation: `AgentFileExplorerState.directories`/`files` cache directory listings inside the `workspaceId`-keyed explorer map. Same-`cwd` workspaces therefore keep duplicate caches, but they can never diverge — both fetch the identical directory via `listDirectory(workspaceRoot, …)`. This is duplication, not leakage, and is left as-is.
## Agent providers
@@ -258,6 +446,8 @@ All providers:
- Map tool calls to a normalized `ToolCallDetail` type
Providers that can accept native tool definitions should set `supportsNativePaseoTools` and read `launchContext.paseoTools`. The daemon then passes the shared Paseo tool catalog directly and removes the internal Paseo MCP server from that provider launch config. Providers that only support MCP continue to receive the same tools through the MCP fallback at `/mcp/agents`.
├── agents/{cwd-with-dashes}/{agent-id}.json # Agent record + persisted timeline rows
├── agents/{cwd-with-dashes}/{agent-id}.json # Agent record
├── projects/projects.json # Project registry
├── projects/workspaces.json # Workspace registry
├── chat/ # Chat rooms
├── projects/icons/ # Custom project icon images
├── schedules/ # Scheduled-agent definitions and runs
├── loops/ # Loop runs and logs
├── config.json # Daemon config (mutable)
├── daemon-keypair.json # Daemon identity for relay/E2EE
├── push-tokens.json # Mobile push tokens
├── paseo.sock / paseo.pid # Local IPC socket and pidfile
├── paseo.pid # Supervisor identity and published bound endpoint
└── daemon.log # Daemon trace logs (rotated)
```
The supervisor alone publishes its ready worker's endpoint in `paseo.pid`, clears it before respawn, and fails if any worker exits before first readiness. CLI home selection trusts only that live record; config expresses desired state, never an endpoint fallback. POSIX home stop signals the captured supervisor without TCP. Windows graceful stop and ordinary RPCs trust the published endpoint. This metadata is not cryptographic listener ownership: edited endpoints, copied identities, PID reuse, and address takeover races remain outside that guarantee. A stale heartbeat never permits reclaiming a live lock.
Worker restart retains supervisor arguments/environment and rereads the configuration file. Updating a package and observing its new worker version do not refresh the running supervisor code; the launcher owns full-process replacement. See [CLI lifecycle contracts](../public-docs/cli.md#daemon-management).
## Deployment models
1.**Local daemon** (default): `paseo daemon start` on `127.0.0.1:6767`
2.**Managed desktop**: Electron app spawns daemon as subprocess
2.**Managed desktop**: Electron uses the server package’s local-instance lifecycle capability for sanitized launch, published readiness, and captured-PID stop. Only a matching `{pid, startedAt}` from a spawn in the current Desktop session authorizes automatic stop or binary replacement. Preexisting instances, including legacy `desktopManaged` records, are attach-only. Keep-running survivors attach in the next session. Explicit attached Stop requires a confirmation naming the captured home/PID. Ordinary Restart uses the worker RPC on every app platform.
@@ -14,6 +14,10 @@ For testing rules, see [testing.md](testing.md).
- **`interface`** over `type` when both work.
- **No `index.ts` barrel files** that only re-export — they create indirection and circular-dep risk. Import from the source.
## Shell scripts
- Bash scripts always use `#!/usr/bin/env bash`. Never hard-code `/bin/bash` or `/usr/bin/bash`; those paths are not portable to environments such as NixOS.
## Comments and noise
- Delete any comment where removing it loses zero information. Comments explain _why_, not _what_.
@@ -47,6 +51,8 @@ For testing rules, see [testing.md](testing.md).
- Catch blocks branch on `instanceof` for what they can handle; rethrow the rest. No `catch (e) { return null }`.
- Separate user-facing copy from log/debug strings — don't make one string serve telemetry, logs, and the UI.
- Fail explicitly. If the caller asked for X and X isn't available, throw — don't silently substitute Y.
- Every fallible user action owns explicit pending, success, and failure UI. Console logs and unverified platform alerts do not satisfy this contract. See [testing.md](testing.md#fallible-user-actions).
- A capability advertised to a client means the current runtime can perform the action, not merely that its RPC handler exists. If an unavailable action needs explanatory UI, send the runtime fact or reason separately and keep the server-side refusal fail-closed.
## Density
@@ -84,10 +90,22 @@ For testing rules, see [testing.md](testing.md).
- Components render and dispatch — they don't compute transitions. Two-plus interacting `useState`s → extract a reducer.
- Never define components inside other components. Module-scope only.
- Subscribe narrowly: select primitives from stores, pass `status` not `agent`, use `useShallow` / deep-equal when returning derived arrays/objects.
- Collection rows do not independently subscribe to a high-frequency global store. The collection owner selects structurally shared indexes once, derives a keyed row model with `useMemo`, and passes entries to rows. This keeps retained hidden collections current without running one selector per row on every store update.
- Equality functions prevent React renders; they do not prevent selector callbacks from running. A selector attached to a hot store must be O(1) when its relevant source references have not changed.
- Retained native panels use `RetainedPanel`. If an existing gesture/layout wrapper must own visibility, wrap its contents in `RetainedPanelActivity` instead. Keep keyed panel roots in a stable sibling order, include the newly active panel in the same render, centralize subscriptions, and gate genuine effects through `useRetainedPanelActive`. Follow the [native ownership constraints and chat suspension boundary](mobile-panels.md) when stopping hidden rendering.
- Infinite animations are subscriptions. Start them only while their retained panel is active, and cancel a shared clock when its final active consumer leaves. Synchronized animations use one clock per animation family and feed active instances through local shared values; retained hidden instances stay mounted but unsubscribed. Match state updates to the actual visual cadence; do not run every style worklet at 60 fps when the rendered value changes only a few times per second.
- Stable references for props that cross `memo` boundaries or feed dependency arrays. Static literals at module scope `as const`; derived with `useMemo`; handlers with `useCallback` only when there's a memoized beneficiary.
- Use stable ids for `key`, never array index for reorderable/filterable lists.
- Context for stable values (theme, auth). Store with selectors for state that changes.
### Retained panel measurements
Inactive retained panels use `display: none`, so DOM and layout APIs report zero width and height. A non-positive measurement means the surface is not rendered; it is never a valid compact layout.
- Suspend geometry-derived updates while either measured dimension is non-positive. Preserve the last valid state until the surface reports positive geometry again.
- Enforce the rule at the measurement boundary shared by every trigger. Guarding only an `onLayout` or `ResizeObserver` callback still lets effects, content changes, and imperative remeasurement publish hidden geometry.
- Test the hide-and-reveal path through a real retained surface. Settled assertions do not catch stale geometry painted during the first returned frames.
## Naming
- Names describe meaning, not mechanics. `submitForm` over `handleOnClickButtonSubmit`. `running` over `filteredArrayOfRunningAgents`.
Paseo supports configuring custom agent providers through `config.json` (located at `$PASEO_HOME/config.json`, typically `~/.paseo/config.json`). You can extend built-in providers with different API backends, add ACP-compatible agents, set custom binaries, disable providers, and create multiple profiles for the same underlying provider.
All provider configuration lives under `agents.providers` in config.json:
Provider definitions live under `agents.providers` in config.json:
```json
{
@@ -17,6 +17,49 @@ All provider configuration lives under `agents.providers` in config.json:
Provider IDs must be lowercase alphanumeric with hyphens (`/^[a-z][a-z0-9-]*$/`).
Each provider catalog refresh waits up to 2 minutes. If a provider loads many plugins or a large
agent catalog during startup, raise the limit in milliseconds:
```json
{
"agents": {
"catalogRefreshTimeoutMs": 180000
}
}
```
The limit applies independently to every provider refresh and covers availability plus the entire
catalog probe. `PASEO_PROVIDER_REFRESH_TIMEOUT_MS` sets it when the config field is absent.
## Provider options
Set provider defaults in `agents.providers.<id>.options`. At agent creation, use
`providerOptions` on the wire or `config.options` in the SDK. Both are opaque
`Record<string, unknown>` values: the provider owns validation and application.
Claude, Codex, and OpenCode reject unknown keys with a key-specific creation error.
Providers without option handling ignore the record.
The daemon deep-merges the agent's options over the provider defaults at each
session launch or resume. Plain objects merge recursively; arrays, scalars, and
`null` replace the base value. Only the agent's own options are persisted, so edits
to provider defaults apply on the next launch or resume. A profile inherits its
base provider's options unless it sets its own options record.
You can create multiple entries that extend the same built-in provider. Each gets its own entry in the provider list with independent credentials, models, and environment.
"Profile" here means a provider alias, and it is not an **Agent profile** — that is a named bundle of provider, model, mode, thinking option and features, stored under `daemon.agentProfiles`. See [glossary.md](glossary.md) for all four senses of the word.
Example: two different Anthropic accounts as separate profiles:
```json
@@ -347,9 +392,9 @@ Override the command used to launch any provider with the `command` field. This
The `command` array completely replaces the default command for that provider. The binary must exist on the system — Paseo checks for its availability and will mark the provider as unavailable if not found.
### Pi-compatible forks with their own session directory
### OMP profiles and Pi-compatible forks
OMP already ships as a built-in provider option. It is disabled by default; enable it with:
OMP ships as a first-class built-in provider option. It is disabled by default; enable it with:
```json
{
@@ -361,6 +406,35 @@ OMP already ships as a built-in provider option. It is disabled by default; enab
}
```
Custom OMP profiles should extend `omp`. They inherit the OMP adapter's `rpc-ui` approvals, native Paseo host tools, provider-managed subagents, and import behavior:
`options.sessionDir` is used only for importing sessions that were started outside Paseo. If `command` or XDG env vars move OMP's state directory, set `options.sessionDir` to the resulting OMP JSONL session directory; launching and resuming still go through the configured command. OMP waits 20 seconds for its initial `ready` frame and 60 seconds for later control-plane RPCs by default. `options.rpcTimeoutMs` overrides both deadlines.
For other providers that keep Pi's `--mode rpc` API but write sessions somewhere else, extend `pi`, replace the command, and provide the JSONL session directory:
```json
@@ -371,8 +445,9 @@ For other providers that keep Pi's `--mode rpc` API but write sessions somewhere
"extends": "pi",
"label": "My Pi Fork",
"command": ["my-pi-fork"],
"params": {
"sessionDir": "~/.my-pi-fork/sessions"
"options": {
"sessionDir": "~/.my-pi-fork/sessions",
"rpcTimeoutMs": 60000
}
}
}
@@ -380,7 +455,7 @@ For other providers that keep Pi's `--mode rpc` API but write sessions somewhere
}
```
The session directory is used only for importing sessions that were started outside Paseo. Launching and resuming still go through the configured command, so this example resumes with `my-pi-fork --mode rpc --session <session-file>`.
This session directory is also import-only. Launching and resuming still go through the configured command, so this example resumes with `my-pi-fork --mode rpc --session <session-file>`.`options.rpcTimeoutMs` overrides the 60-second Pi control-plane RPC deadline.
---
@@ -438,7 +513,7 @@ Required fields for ACP providers:
- `label`
- `command` — the command to spawn the agent process (must support ACP over stdio)
By default, Paseo injects its internal MCP server into ACP providers so agents can use Paseo tools such as subagent creation. Some ACP adapters cannot create sessions when `mcpServers` is non-empty. Disable injected MCP for those providers with `params.supportsMcpServers: false`:
Paseo tools such as subagent creation come from the shared internal tool catalog. ACP providers receive those tools through the MCP fallback by default because ACP exposes `mcpServers`, not Paseo's native tool catalog. Some ACP adapters cannot create sessions when `mcpServers` is non-empty. Disable injected MCP for those providers with `options.supportsMcpServers: false`:
```json
{
@@ -448,7 +523,7 @@ By default, Paseo injects its internal MCP server into ACP providers so agents c
"extends": "acp",
"label": "My Agent",
"command": ["my-agent", "acp"],
"params": {
"options": {
"supportsMcpServers": false
}
}
@@ -457,6 +532,39 @@ By default, Paseo injects its internal MCP server into ACP providers so agents c
}
```
ACP agents execute filesystem operations in their own environment by default,
while terminal operations run through Paseo on the host. To customize which
operations Paseo handles, configure client capabilities in provider options:
```json
{
"agents": {
"providers": {
"container-agent": {
"extends": "acp",
"label": "Container Agent",
"command": ["container-agent", "acp"],
"options": {
"clientCapabilities": {
"fs": {
"readTextFile": false,
"writeTextFile": false
},
"terminal": false
}
}
}
}
}
}
```
When an agent runs in a container or remote environment that manages its own
terminal, set `terminal: false` to keep command execution inside the agent
container. When delegating filesystem operations to Paseo (`fs.readTextFile: true`
or `fs.writeTextFile: true`), ensure the agent and Paseo share equivalent
absolute workspace paths.
### Generic ACP diagnostics
Paseo diagnostics for `extends: "acp"` providers report the configured command, resolved launcher binary, version output, ACP `initialize`, ACP `session/new`, model count, modes, and final status.
@@ -525,6 +633,11 @@ When you launch an agent with an ACP provider:
4. Paseo creates a session and sends prompts through the ACP protocol
5. The agent streams responses, tool calls, and permission requests back over stdout
Every ACP provider exposes an **Auto Accept** toggle. Enable it per session to let Paseo approve
ACP permission requests without surfacing each prompt. If the provider sends no allow option,
Paseo leaves the request for you to answer. Unattended agents enable Auto Accept unless you
explicitly disable it.
Models and modes are discovered dynamically at runtime from the agent process. If you want to override the model list (e.g., to curate which models appear in the UI), use the `models` field:
```json
@@ -600,7 +713,7 @@ Every entry under `agents.providers` accepts these fields:
| `description` | `string` | No | Short description shown in the UI |
| `command` | `string[]` | Yes (ACP only) | Command to spawn the agent process |
| `env` | `Record<string, string>` | No | Environment variables to set for the agent process |
| `params` | `Record<string, unknown>` | No | Provider-specific options such as `supportsMcpServers: false` |
| `options` | `Record<string, unknown>` | No | Provider-specific options such as `supportsMcpServers: false` |
| `models` | `ProviderProfileModel[]` | No | Static model list (overrides runtime discovery) |
| `additionalModels` | `ProviderProfileModel[]` | No | Static model additions (merged with runtime discovery or `models`) |
| `disallowedTools` | `string[]` | No | Tool names to disable for this provider (e.g. `["WebSearch"]`) |
@@ -630,9 +743,9 @@ Each entry in the `models` array:
### Claude settings.json model discovery
The built-in `claude` provider appends concrete model IDs from `~/.claude/settings.json` to its first-party Claude model list. Paseo reads the top-level `model` field and these `env` keys: `ANTHROPIC_MODEL`, `ANTHROPIC_SMALL_FAST_MODEL`, `ANTHROPIC_DEFAULT_OPUS_MODEL`, `ANTHROPIC_DEFAULT_SONNET_MODEL`, and `ANTHROPIC_DEFAULT_HAIKU_MODEL`.
The built-in `claude` provider appends concrete model IDs from `~/.claude/settings.json` to its first-party Claude model list. Paseo reads the top-level `model` field and these `env` keys: `ANTHROPIC_MODEL`, `ANTHROPIC_SMALL_FAST_MODEL`,`ANTHROPIC_DEFAULT_FABLE_MODEL`,`ANTHROPIC_DEFAULT_OPUS_MODEL`, `ANTHROPIC_DEFAULT_SONNET_MODEL`, and `ANTHROPIC_DEFAULT_HAIKU_MODEL`.
This lets users who already configured Claude Code for Bedrock, OpenRouter, ollama, Z.AI, or another Anthropic-compatible gateway select the exact model ID in Paseo. When `agents.providers.claude.models` is set it **replaces** both the hardcoded first-party Claude list and any settings.json-discovered entries; use `agents.providers.claude.additionalModels` to keep the first-party list and append curated entries on top.
This lets users who already configured Claude Code for Bedrock, OpenRouter, ollama, Z.AI, or another Anthropic-compatible gateway select the exact model ID in Paseo. Explicit model IDs are passed unchanged to Claude Code, even when the same string is a compatibility alias for a built-in model. When `agents.providers.claude.models` is set it **replaces** both the hardcoded first-party Claude list and any settings.json-discovered entries; use `agents.providers.claude.additionalModels` to keep the first-party list and append curated entries on top.
### Gotcha: `extends: "claude"` with third-party endpoints
Projects are allocated for the exact root selected by the caller, normalized lexically with `path.resolve` (never `realpath`). New project IDs are opaque `prj_<16 hex>` values. Existing remote-shaped or path-shaped IDs are retained as readable compatibility records and are never rekeyed. An active exact root is idempotent; archived-only matches do not resurrect an old project. Workspace `projectId` is stable membership: reconciliation may update git-derived kind and branch metadata, but never rehomes a workspace or changes a project's root, ID, or default name.
`projectKey` is a persisted, opaque equivalence key used only to group the same logical project
across hosts. It is separate from the host-local `projectId`; today's producer prefers a normalized
Git remote and otherwise uses the local project root. Consumers never derive it from live Git.
Creation persists it with the project, and normal boot reconciliation fills it for
older records where the field is absent—there is no migration.
`kind` and `projectKey` are mutable metadata, not identity. Workspace reconciliation watches active project roots and
updates those fields and `updatedAt` when Git facts change, preserving the project's ID, root path,
names, and workspace foreign keys. Attached workspaces are independently refreshed
from their own cwd, so an explicit project root never implies a workspace checkout. Empty projects
are observed too.
The workspace registry model defines placement once: initial directory/worktree construction,
mutable reconciliation fields, and the persisted-to-wire checkout projection. Its update policy
preserves `displayName` and `baseBranch`. `WorkspaceProvisioningService` owns the corresponding
registry writes, so directory opens, agent imports, and worktree creation all enter through that
service instead of constructing records independently. The workspace record is then the durable
placement authority: `cwd` is the exact execution directory, while `worktreeRoot` is the backing
checkout root. They intentionally differ for an exact subproject inside a worktree. Archive,
restore, branch auto-name, and descriptor flows consume those persisted facts rather than
rediscovering ownership from a directory that may already be gone. Reconciliation may refresh
mutable placement facts, but never changes `projectId`, `cwd`, `displayName`, or `baseBranch`.
Workspace archive runs lifecycle teardown from the exact `cwd` but removes only the backing
`worktreeRoot` after its last active reference disappears. Worktree recovery recreates that backing
checkout from `mainRepoRoot`, then restores the relative path from `worktreeRoot` to `cwd`.
Paseo uses **file-based JSON persistence** instead of a traditional database. All data is validated at runtime with Zod schemas. Most stores write atomically (write to temp file, then rename); a few still use plain `writeFile` — see each section. There is no schema-versioning/migration framework — schemas rely on optional fields with defaults for forward compatibility, with a small amount of inline normalization in `persisted-config.ts` for legacy provider/speech entries.
All server-side stores live under `$PASEO_HOME` (defaults to `~/.paseo`).
## Store Surface Rules
Store APIs own persistence atomicity and should not make services coordinate raw reads and writes. A good store method maps cleanly to one SQL statement or one SQL transaction, even when the current implementation is JSON files. If a caller needs a queue, lock, read-merge-write loop, or uniqueness race workaround, that behavior belongs behind the store surface.
| `updatedAt` | `string` (ISO 8601) | Last update timestamp |
| `lastActivityAt` | `string?` (ISO 8601) | Last activity timestamp |
| `lastUserMessageAt` | `string?` (ISO 8601) | Last user message timestamp |
| `title` | `string?` | User-visible title |
| `labels` | `Record<string, string>` | Key-value labels (default `{}`). `paseo.parent-agent-id` set automatically when launched via the `create_agent` MCP tool — see [agent-lifecycle.md](./agent-lifecycle.md) |
| `cwd` | `string` | Working directory the agent operates in |
| `workspaceId` | `string?` | Owning workspace id — the single source of ownership. Every agent is stamped with one at create time; legacy cwd-only records are backfilled once by `migrations/backfill-workspace-id.migration.ts` (the only place a cwd→id mapping exists). Runtime code never infers ownership or status from cwd: status is computed per `workspaceId`, and same-cwd siblings are independent. |
| `createdAt` | `string` (ISO 8601) | Creation timestamp |
| `updatedAt` | `string` (ISO 8601) | Last update timestamp |
| `lastActivityAt` | `string?` (ISO 8601) | Last activity timestamp |
| `lastUserMessageAt` | `string?` (ISO 8601) | Last user message timestamp |
| `title` | `string?` | User-visible title |
| `labels`| `Record<string, string>` | Key-value labels (default `{}`). Paseo uses `paseo.parent-agent-id` for parentage and client-scoped `paseo.open-agent-tab.*` labels while managed subagent tabs are open — see [agent-lifecycle.md](./agent-lifecycle.md) |
| `lastStatus` | `AgentStatus`| One of: `"initializing"`, `"idle"`, `"running"`, `"error"`, `"closed"`. `closed` means the record is resumable but has no live provider runtime; archive remains represented separately by `archivedAt`. |
| `lastModeId` | `string?` | Last active mode ID |
| `config`| `SerializableConfig?`| Agent session configuration (see below) |
| `runtimeInfo` | `RuntimeInfo?` | Live runtime state (see below) |
| `features` | `AgentFeature[]?` | Provider-reported features (toggles/selects) |
| `persistence` | `PersistenceHandle?` | Handle for resuming sessions |
| `lastError` | `string?` (nullable) | Last error message, if any |
| `requiresAttention` | `boolean?` | Whether the agent needs user attention |
| `attentionTimestamp` | `string?` (ISO 8601) | When attention was flagged |
| `internal` | `boolean?` | Whether this is a system-internal agent |
| `archivedAt` | `string?` (ISO 8601) | Soft-delete timestamp |
### Nested: SerializableConfig
@@ -126,22 +167,47 @@ Each agent is stored as a separate JSON file, grouped by project directory.
---
## Runtime-only Terminal Sessions
Terminals are live daemon state, not persisted JSON records. A terminal carries a `workspaceId` while it is running; workspace-scoped terminal lists include only terminals with the matching `workspaceId`. Legacy live terminals without an owner remain visible to unscoped terminal reads but contribute to no workspace status.
Terminal activity contributes to the workspace status bucket **per `workspaceId`**: a working terminal drives `running` onto the workspace it carries only. Same-`cwd` siblings are untouched; terminal visibility is likewise `workspaceId`-scoped.
---
## 2. Daemon Configuration
**Path:** `$PASEO_HOME/config.json`
Single file, validated with `PersistedConfigSchema`.
`agents.skills.selection` is the daemon host's orchestration-skill preference. Missing means
`{ mode: "all" }`. Installed state is not persisted; the daemon derives it from the shared
`~/.agents/skills` and Claude skill directories and keeps config plus filesystem convergence behind
one serialized owner. Codex discovers the shared directory. Updates retire unchanged files from
Paseo-managed copies in `~/.codex/skills`, preserving user edits and untracked files.
`paseo reload` reads and validates this file once inside the daemon. That snapshot drives resolution,
classification, application, and reload bookkeeping. `DaemonConfigStore` owns applying runtime-safe
fields and their removal/default semantics; session handlers and the CLI only relay the structured
result. Normal config patches persist only the requested fields, so launch overrides and resolved
defaults never leak into the file. Startup-only fields remain compared with the daemon's launch
snapshot so a mixed edit can apply its live subset and still name the paths that require restart.
```
{
version: 1,
daemon: {
listen: "127.0.0.1:6767",
hostnames: true | string[], // legacy alias `allowedHosts` is migrated on load
IDs to omit. The policy covers the core and browser catalog, not the voice-only `speak` tool.
Browser tools also require `daemon.browserTools.enabled` and a connected browser host.
This policy controls the catalog presented to an agent. It is not an authorization boundary for
agents that can access the host through a shell.
`daemon.mcp.injectIntoAgents` is the global override. When it is `false`, no provider receives
Paseo tools; otherwise the provider policy applies. Provider and global policy are resolved when a
session is created, resumed, imported, or reloaded, so configuration changes affect the next
session rather than an already-running one.
`agents.metadataGeneration.providers` controls the preferred structured-generation fallback order for daemon-side metadata tasks such as commit messages, PR text, branch names, and generated agent titles. Entries are tried first in the configured order, then Paseo falls through to dynamically discovered defaults and finally the current selection when available.
### Git process limits
Git process limits are global to one daemon. The start-rate limit defaults to `64` processes per
second, and the concurrency limit defaults to `8`:
```json
{
"daemon": {
"git": {
"maxProcessesPerSecond": 64,
"maxProcessConcurrency": 8
}
}
}
```
`maxProcessesPerSecond` limits Git process starts in any one-second interval. The allowance can
start as a burst; it does not wait for earlier processes to exit. `maxProcessConcurrency` limits
the number of Git processes that have started but not exited. Every Git command uses both limits,
including initial workspace reads, filesystem-triggered refreshes, background checks, and explicit
| `PASEO_GIT_CONCURRENCY` | Legacy concurrency alias |
`PASEO_GIT_MAX_PROCESS_CONCURRENCY` wins when it and the legacy alias are both set. Run `paseo reload`
after changing `config.json`. Environment changes require a daemon restart; the launch environment
remains authoritative during reload.
`agents.metadataGeneration.providers` controls the preferred structured-generation fallback order for daemon-side metadata tasks such as commit messages, PR text, branch names, and generated agent titles. Entries are tried first in the configured order, then Paseo falls through to dynamically discovered defaults and finally the current selection when available.
Local speech model ids are intentionally narrow: STT uses `parakeet-tdt-0.6b-v2-int8`, TTS uses `kokoro-en-v0_19`, and turn detection uses the bundled Silero VAD model.
Set these to select OpenAI instead of local speech:
OpenAI speech can be configured under `providers.openai`. STT and TTS resolve independently, so they can point at different endpoints:
```json
{
"providers": {
"openai": {
"stt": {
"apiKey": "sk-...",
"baseUrl": "https://stt.example.com/v1"
},
"tts": {
"apiKey": "sk-...",
"baseUrl": "https://api.openai.com/v1"
}
}
}
}
```
`providers.openai.stt` is used for both composer dictation and voice mode speech-to-text; `providers.openai.tts` is used for voice mode text-to-speech. The equivalent env vars are `OPENAI_STT_API_KEY`/`OPENAI_STT_BASE_URL` and `OPENAI_TTS_API_KEY`/`OPENAI_TTS_BASE_URL`. Each feature falls back to `providers.openai.apiKey`/`providers.openai.baseUrl`, then `OPENAI_API_KEY`/`OPENAI_BASE_URL`, when its own fields are unset. These settings apply only to Paseo OpenAI speech features, not to Codex or other OpenAI-backed tools.
Paseo uses these paths under the configured OpenAI base URL:
- dictation STT: `/v1/audio/transcriptions`
- voice mode STT: `/v1/audio/transcriptions`
- voice mode TTS: `/v1/audio/speech`
---
## 3. Schedule
@@ -208,13 +419,13 @@ One file per schedule. ID is 8 hex characters.
### Nested: ScheduleCadence (discriminated union on `type`)
- `{ type: "every", everyMs: number }` — interval in milliseconds
- `{ type: "cron", expression: string, timezone?: string }` — cron expression; absent `timezone` means UTC, present `timezone` is an IANA time zone used for local wall-clock recurrence
- `{ type: "cron", expression: string, timezone?: string }` — canonical cadence for new writes; absent `timezone` means UTC
- `{ type: "every", everyMs: number }` — legacy rolling interval, still readable and executable during the compatibility window
### Nested: ScheduleTarget (discriminated union on `type`)
Single file containing an array of all loop records. Writes are direct (not atomic) and serialized through an in-memory queue. On daemon startup any record with `status: "running"` is recovered as `"stopped"` with an interruption log entry.
| `kind` | `"git" \| "non_git"` | Mutable Git observation about `rootPath`, never a membership key |
| `displayName` | `string` | Selected-root basename, stable across remote and Git changes |
| `customName` | `string \| null` | User-set override layered over `displayName`. Null means "use the derived name". |
| `customIconRevision` | `string \| null` | Identifies the host-local custom icon stored under `projects/icons/`. Null means the icon is discovered by scanning the project directory. |
| `workspaceId` | `string` | Opaque stable identifier (`wks_<hex>`), generated independently of the directory. MUST NOT be treated as a path; compare by exact equality. Use the `cwd` field for directory access. |
| `projectId` | `string` | FK to Project.projectId; the workspace's stable project membership |
| `cwd` | `string` | Exact execution directory selected for agents, files, scripts, and setup |
| `displayName` | `string` | The human name (the generated/derived title). Decoupled from `branch` by construction. |
| `title` | `string \| null` | User-set name override layered over `displayName`. Null means "use `displayName`". |
| `branch` | `string \| null` | The current Git branch for git-backed workspaces. Separate from `displayName`/`title`; a background branch refresh never rewrites the name. |
| `worktreeRoot` | `string \| null`| Backing checkout/worktree root. May differ from `cwd` for exact subprojects and remains persisted after the worktree is deleted so restore can reproduce the placement. |
| `baseBranch` | `string \| null` | Comparison base retained across archive and restore. Branch-off creation stores the resolved ref; legacy and PR-checkout records hold a bare name. Null means no recorded base. |
| `isPaseoOwnedWorktree` | `boolean` | Whether Paseo owns and may remove/recreate the backing `worktreeRoot` |
| `mainRepoRoot` | `string \| null` | Main repository root for worktree checkouts, independent of both exact `cwd` and backing `worktreeRoot` |
| `autoArchivedChangeRequestUrl` | `string \| null` | Change request whose merged state triggered auto-archive. Restore replaces it with the current merged change request, when present, so repeated snapshots cannot archive the workspace again. |
| `labels` | `string[]?` | Normalized display names assigned from this host's shared label catalog. Missing means unlabelled. |
| `pinnedAt` | `string \| null` (ISO 8601) | Pinned-to-top-of-sidebar timestamp; null means "not pinned" |
| `untrustedSource` | `{ kind: "change_request", forge, number, headRepository }?` | Provenance captured when a cross-repository change request creates the workspace. Missing means repository automation is allowed; explicit setup removes the field. |
> **Opaque-ID invariant:**`workspaceId` is opaque identity, never a filesystem path. Filesystem and git operations take `cwd`/`workspaceDirectory` only — never the id. A compatibility-only first-materialization bootstrap still groups pre-registry agent records by path and Git remote so existing installs retain their legacy records. That grouping never runs against a live registry, and its keys are not runtime project or workspace identity.
The catalog is shared by every workspace on one host. A definition contains a display name and one
of the ten identity colour names (`WORKSPACE_LABEL_COLORS` in
`packages/protocol/src/workspace-labels.ts`); trimmed, collapsed, case-insensitive name identity is
unique within that file. Definitions have no portable ID or principal owner and remain after their
last assignment. Editing a label takes a new name, a new colour, or both in one commit, so the two
fields cannot land half-applied. Workspaces store label names, so a rename and a delete rewrite
workspace assignments through one serialized compound commit while a recolour is catalog-only; a
rename onto a name the host already has is refused rather than merged. A prepared
`workspace-labels.transaction.json` contains both before and after images. The daemon writes the
catalog and workspace files, then atomically changes the transaction to committed; that phase
change is the durable commit point. Recovery rolls prepared transactions back before either
directory is served. A committed marker proves both data files were already written, so recovery
only loads the current catalog and retries marker cleanup; it never reapplies stale workspace
after-images over later registry mutations. A request rejected before the commit point therefore
cannot take effect after restart. If the live daemon cannot determine or restore the durable state,
the workspace registry freezes every write and later label mutations fail with
`workspace_label_storage_uncertain` until daemon restart performs recovery. Reads remain available,
but may reflect the last acknowledged cache until restart. Workspace directory and catalog updates
publish only after the commit point; publication failure does not roll durable state back.
`projectId` is still a real FK: workspace records should have a matching project record. Read-only
history surfaces tolerate transient orphaned workspaces by omitting those rows so one bad FK cannot
blank the whole History screen, but mutation paths should repair or remove the orphaned state rather
than treating it as valid.
---
## 8. Push Token Store
## 6. Push Token Store
**Path:** `$PASEO_HOME/push-tokens.json`
@@ -413,7 +551,7 @@ Simple set of Expo push notification tokens. Loaded with permissive parsing (fil
---
## 9. Daemon meta files
## 7. Daemon meta files
These small files are not validated as full Zod schemas but are persisted under `$PASEO_HOME` for daemon identity and runtime coordination.
@@ -422,6 +560,7 @@ These small files are not validated as full Zod schemas but are persisted under
| `server-id` | Plain text, e.g. `srv_<base64url>` | Stable per-`$PASEO_HOME` daemon ID. Overridable via `PASEO_SERVER_ID` env. |
| `daemon-keypair.json` | `{ v: 2, publicKeyB64, secretKeyB64 }` (libsodium box keypair) | E2EE relay identity. Written with mode `0600`. Regenerated if file is unreadable. |
| `paseo.pid` | JSON `{ pid, startedAt, ... }` | PID lock; prevents two daemons sharing one `$PASEO_HOME`. |
| `local-credential` | 32 random bytes encoded as base64url text | Rotated before each listen and deleted on shutdown; mode `0600`. |
| `daemon.log` | Pino log output | Default location; path/rotation configurable via `log.file` in `config.json`. |
---
@@ -430,6 +569,33 @@ These small files are not validated as full Zod schemas but are persisted under
These live in React Native `AsyncStorage` or browser `IndexedDB`, not on the daemon filesystem.
### Keying convention: directory-backed vs workspace-owned
Right-sidebar client state splits on whether it is determined by the directory or owned by the workspace (two workspaces can share one `cwd`). The split is enforced by the cache key, so changing a key changes the sharing semantics — see [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned) for the full table.
- **Directory-backed** (shared by same-`cwd` workspaces): keyed by `(serverId, cwd)`. Git status/diff, GitHub PR status, PR timeline, file preview content. These are TanStack Query caches, not persisted stores.
- **Workspace-owned** (independent per workspace): keyed by `workspaceId`, with `cwd` used only as a fallback when no `workspaceId` is present. Review draft comments (`@paseo:review-draft-store`), diff-mode overrides (in-memory), workspace composer attachments, and file-explorer nav/expand state. The `workspaceId` part of these keys is **opaque** — never parse it back into a path.
### Replica row store
The durable client replica uses IndexedDB on browser/Electron and expo-sqlite on native. Rows use the
compound key `(serverId, kind, id)`; kinds are `agent`, `workspace`, `project`, `timeline`, and
`checkpoint`. Directory entities have individual rows, timelines use the agent id, and the checkpoint
uses the singleton id.
The store is a typed persistence boundary. It returns values to directory and timeline owners and
accepts their explicit commits; it never reads or writes UI state. Reads are scoped to the requested
host, kinds, and ids. Opening a cached workspace uses exact workspace and project keys rather than a
directory scan. One invalid row is deleted and returned as a miss without affecting other rows.
An invalid directory row and its affected checkpoint cursor are repaired in one transaction, so a
later launch cannot accept a checkpoint for a partial baseline. Directory changes and their
checkpoint are also applied in one transaction.
The cache is capped at 32 MiB and evicts whole hosts in least-recently-written order. Budget
bookkeeping may scan opaque row sizes during a deferred write, never during host registry startup or
before a requested cache row can paint. The row store is not encrypted. A cached timeline can contain
source code, prompts, and tool output; encrypted-at-rest storage is a separate security decision.
@@ -20,7 +20,7 @@ A semantic element used in three or more places is a primitive. One of a kind is
Primitives live in `packages/app/src/components/ui/` and `packages/app/src/components/headers/`. Card and row layout live in `packages/app/src/styles/settings.ts`. Section structure lives in `packages/app/src/screens/settings/settings-section.tsx`.
A pressable styled to look like a button is wrong; the button is `<Button>` (`packages/app/src/components/ui/button.tsx`). A bare `<Text>` styled to look like a section header is wrong; the section header is `<SettingsSection>` (`packages/app/src/screens/settings/settings-section.tsx`). A custom `Modal` for a confirmation is wrong; the confirmation is `confirmDialog` (`packages/app/src/utils/confirm-dialog.ts`). A hand-rolled overflow menu is wrong; the menu is `<DropdownMenu>` (`packages/app/src/components/ui/dropdown-menu.tsx`). A hand-rolled status pill is wrong; the pill is `<StatusBadge>` (`packages/app/src/components/ui/status-badge.tsx`).
A pressable styled to look like a button is wrong; the button is `<Button>` (`packages/app/src/components/ui/button.tsx`). A bare `<Text>` styled to look like a section header is wrong; the section header is `<SettingsSection>` (`packages/app/src/screens/settings/settings-section.tsx`). A custom `Modal` for a confirmation is wrong; the confirmation is `confirmDialog` (`packages/app/src/utils/confirm-dialog.ts`). A hand-rolled overflow menu is wrong; the menu is `<DropdownMenu>` (`packages/app/src/components/ui/dropdown-menu.tsx`). A hand-rolled status pill is wrong; the pill is `<StatusBadge>` (`packages/app/src/components/ui/status-badge.tsx`). A circle pinned over a scrolling surface is wrong; the floating action is `<FloatingActionButton>` (`packages/app/src/components/ui/floating-action-button.tsx`), one per surface, and the surface reserves `FLOATING_ACTION_BUTTON_CLEARANCE` below its content so the button never traps the last row.
Before adding a new component, read `components/ui/`. The primitive usually exists.
@@ -28,11 +28,13 @@ Before adding a new component, read `components/ui/`. The primitive usually exis
## 3. Hierarchy
Hierarchy is conveyed through weight and color, not size. Most labels, titles, and hints across the app are `fontSize.base` or`fontSize.xs`. The distinction between a row's primary line and its secondary line is `foreground` versus `foregroundMuted`.
Hierarchy is conveyed through weight and color, not size. Most interface text is `fontSize.base`; compact metadata and hints use`fontSize.sm`. The distinction between a row's primary line and its secondary line is `foreground` versus `foregroundMuted`.
The authored interface ramp uses a 14px base. New native installs default to 15px; web and desktop default to 14px. The Appearance **Interface size** setting is the rendered `fontSize.base` value and scales the rest of the UI ramp proportionally. Primary readable content has its own `fontSize.content`, which defaults to 16px on native and 15px on web and desktop. It owns message bodies, composer input, Markdown, and PR prose. Controls, navigation, metadata, tool chrome, code, diffs, editors, and terminals stay on their interface or code tokens. **Code size** remains independent.
Weight has three tiers, applied by role:
- **Screen titles** — the title at the top of a screen — use `<ScreenTitle>` (`packages/app/src/components/headers/screen-title.tsx`), which renders `fontSize.base` at weight `400` on compact and `300` on desktop. Top-of-screen titles are lighter on desktop, not heavier. The workspace screen header follows the same rule (`packages/app/src/screens/workspace/workspace-screen.tsx`).
- **Screen titles** — the title in app chrome — use `<ScreenTitle>` (`packages/app/src/components/headers/screen-title.tsx`), which renders `fontSize.base` at weight `400` on compact and `300` on desktop. The New workspace hero is the only larger product title; it uses `fontSize["2xl"]` (`packages/app/src/screens/new-workspace-screen.tsx`).
- **Structural labels** use `fontWeight.medium`. This applies to section labels above a stack of rows (`packages/app/src/components/agent-list.tsx:519-523`, `packages/app/src/components/keyboard-shortcuts-dialog.tsx:63-67`), form field labels above an input inside a modal (`packages/app/src/components/add-host-modal.tsx:19-23`, `packages/app/src/components/pair-link-modal.tsx:24-28`), the title at the top of a modal/sheet/dialog (`packages/app/src/components/adaptive-modal-sheet.tsx:90-94`, `packages/app/src/components/ui/combobox.tsx:1607-1611`, `packages/app/src/components/welcome-screen.tsx:48-53`), action button labels in tight components such as the sidebar callout actions (`packages/app/src/components/sidebar-callout.tsx:218-221`), and inline data emphasis on dense metadata rows (`packages/app/src/components/git-diff-pane.tsx:2322-2327`, `packages/app/src/components/file-explorer-pane.tsx:1115-1122`).
- **Content** uses `fontWeight.normal`. This applies to settings rows (`packages/app/src/styles/settings.ts`), sidebar primary list-item titles (`packages/app/src/components/sidebar-workspace-list.tsx:2680-2686`, `packages/app/src/components/agent-list.tsx:572-578`), `<Button>` text (`packages/app/src/components/ui/button.tsx:80-84`), `<StatusBadge>` text (`packages/app/src/components/ui/status-badge.tsx:56-60`), and `<SidebarCallout>` titles (`packages/app/src/components/sidebar-callout.tsx:175-180`).
@@ -40,6 +42,8 @@ The rule, condensed: text that _names_ a surface or a group is `medium`. Text th
Foreground is for the thing being acted on: row titles, section headings, the selected sidebar item. `foregroundMuted` is for context: hints, descriptions, secondary metadata, idle sidebar items, placeholders, status text.
`foregroundExtraMuted` is reserved for passive chrome that must sit behind muted text, such as an always-visible window control. Use the solid token instead of lowering SVG opacity; per-path opacity makes overlapping icon strokes render unevenly. Interactive hover and pressed states return to `foreground`.
Accent is the one CTA per surface. A `<Button variant="default">` filled with `accent` appears at most once on a page. Most pages have zero — settings is mostly toggles and text, the workspace pane is mostly content, the chat composer is the input itself.
Destructive is a color, not a click. Restart-daemon and remove-host are `<Button variant="outline">` in the row trailing slot; the destructive surface only appears inside the `confirmDialog` (`packages/app/src/screens/settings/host-page.tsx:541-547`). Workspace archive opens a confirm dialog before any red appears (`packages/app/src/components/sidebar-workspace-list.tsx`). Red appears after the user has indicated intent.
@@ -58,10 +62,16 @@ The button is `<Button>` (`packages/app/src/components/ui/button.tsx`). It has f
`ghost` is structural and non-committal — no border, no fill. Back arrows, header toggles, "Load more" footers (`packages/app/src/screens/sessions-screen.tsx:54-63`), more-affordances. Ghost is used when the affordance is part of the chrome, not a decision.
Header and toolbar controls use `interactionHighlight` for hovered, pressed, open, and selected
backgrounds. It is a translucent semantic fill so the same control works over the main surface and
the sidebar. Apply it as `backgroundColor`; setting `opacity` on the control also fades its content.
`destructive` is filled with `destructive`. It only appears inside a confirm. The button on the page is `outline`; the destructive button is the confirm button inside the dialog.
Sizes: `xs` for ultra-tight inline triggers. `sm` for any button sitting in a row. `md` is the page default. `lg` is reserved for large standalone CTAs.
Sizes are a shared contract across control kinds, defined once in `control-geometry.ts`: `xs` = 28px tall with `fontSize.sm` labels, `sm` = 32px with `fontSize.base`, `md`/`lg` = 44px with `fontSize.base`. `<SegmentedControl>` (`packages/app/src/components/ui/segmented-control.tsx`) takes the same `xs`/`sm`/`md` sizes — a segmented control next to a `<Button>` of the same size always matches in height, label size, and corner radius. Its segments run one padding step tighter than a button, because the gap between segments already reads as padding. The selected segment is a `surface3` fill with `foreground` text, not an inverted one — inverting it inside thin chrome puts a white slab in the toolbar. Thin chrome such as the file toolbar uses `xs`; settings rows use `sm`. Never shrink a control's font or padding locally to fit a context — if the context needs a smaller control, the size tier is missing or the wrong one is in use.
A `<Pressable>` wrapping a `<Text>` is a sixth variant. It is wrong. `<Button>` accepts `style`, `textStyle`, `leftIcon`, `disabled`, `size`, and `variant`.
---
@@ -94,7 +104,7 @@ Five primitives. The pick is determined by option count, the need to search, and
`<AdaptiveModalSheet>` is for a focused task. Multi-field forms (`packages/app/src/components/add-host-modal.tsx`, `packages/app/src/components/pair-link-modal.tsx`, `packages/app/src/components/project-picker-modal.tsx`), confirmations with detail, anything that earns a backdrop. Bottom sheet on compact, centered card on desktop. Raw `Modal` is wrong for any of these.
`<AdaptiveModalSheet>` owns compact bottom safe-area padding inside the sheet so the sheet background still reaches the screen bottom. If a sheet's first snap point is shorter than its header, content, and safe-area clearance, raise that snap point rather than moving the sheet container.
`<AdaptiveModalSheet>` owns the presentation. Its content inset — the gutter that puts sheet content on the same rails as the sheet header — and compact bottom safe-area padding are the sheet's, not the caller's. A caller declares layout intent through `contentStyle` and never branches on form factor to add its own margins. If a sheet's first snap point is shorter than its header, content, and safe-area clearance, raise that snap point rather than moving the sheet container.
`confirmDialog` is for destructive yes/no and imperative confirmation. Promise-based: `await confirmDialog({ destructive: true, ... })`. Anything where a wrong click loses work.
@@ -110,6 +120,8 @@ Workspace and chat surfaces use the full width — these are working surfaces, n
Sections sit apart. `<SettingsSection>` owns its own bottom margin; the next thing is wrapped in another `<SettingsSection>`. The agent-list `sectionHeading` carries the same `marginTop`/`marginBottom` rhythm (`packages/app/src/components/agent-list.tsx:511-517`). Adding `marginBottom` to a section is wrong.
A section or group explains itself through the `info` prop on `<SettingsSection>` or `<SettingsGroup>` — an info icon beside the header that opens a tooltip (`packages/app/src/components/settings/headings/settings-info-tip.tsx`). A muted paragraph between the header and the card is wrong: it sits in the section's own gap, so it reads as a second heading rather than as prose belonging to the header. Explanatory copy that describes one row belongs to that row, as `settingsStyles.rowHint` inside the card.
Cards inside a section sit closer than sections. Rows inside a card touch — only the divider separates them. The rhythm is page → spacious; section → spacious; card → tight.
Rows have generous vertical padding: roughly 16px of content plus 16px of vertical padding for settings rows, 8–12px for sidebar list items where many rows must fit. Compressing rows below the established density to fit more on the screen is wrong. Too many rows means more cards or more sections, not smaller rows.
@@ -118,7 +130,21 @@ The whitespace is the design.
---
## 8. Responsiveness
## 8. Alignment
Things align to their glyphs, not to their boxes. A row's leading icon, its title, and the label of the button in its trailing slot sit on the same rails — the ink lines up, not the padding, not the touch target, not the hover background.
Pick the rails from the content, then hold them. A settings card establishes a leading rail at the icon's left edge and a trailing rail at the last glyph's right edge; every row in that card uses the same two. A row whose icon is absent still starts its title on the leading rail. Indentation is a new rail, not an arbitrary offset.
The pressable is bigger than the glyph, and that is fine. Hit areas grow outward from the aligned content — they never move it. A button that looks two pixels off because its padding is asymmetric is misaligned even though its box is correct.
Optical alignment beats arithmetic when a glyph disagrees with its bounding box. Icons with visual weight on one side, chevrons, and single-character labels usually need a small nudge to look centered. Trust the eye, then leave a comment saying the offset is optical.
One row off the rail makes the whole card look unconsidered.
---
## 9. Responsiveness
Compact-first. The small case is designed; the large case adds chrome around it.
@@ -131,11 +157,17 @@ The branching is one `useIsCompactFormFactor()` check at the top of the screen c
The workspace screen (`packages/app/src/screens/workspace/workspace-screen.tsx`) follows a different but parallel rule: tabs collapse on compact, panes split on desktop. The sidebar (`packages/app/src/components/left-sidebar.tsx`) is overlaid on compact and pinned on desktop.
On a narrow desktop route, app navigation yields to the rendered content topology when the remaining width cannot preserve its center target: Settings keeps its 320px list + 400px detail split, and a workspace Explorer keeps its current visible width plus a 400px center pane. That is a topology decision at the app container, not a second compact breakpoint. Temporary width clamps are render-only; widening restores the user's saved sidebar widths.
Electron window controls are top-corner obstructions, not a compact-layout condition. Rendered surfaces declare which top corners they physically occupy; only those corners receive clearance. Full-window overlays redeclare both corners. A focused split pane owns both corners; if focus restoration temporarily exposes the full split tree, the split boundary reserves one top strip instead of assigning a control rectangle to an arbitrarily narrow leaf. The 720px desktop breakpoint preserves the default 320px sidebar and target 400px center width when the Explorer is closed; it is product policy, not an obstruction gate.
Windows and Linux controls are fixed window chrome, outside scrolling header content. A tab rail that reaches them ends at their obstruction and shows the shared overflow fade. On macOS, the Explorer toggle occupies a fixed top-right window slot so opening and closing Explorer does not move the pointer target.
A new list+detail feature copies the settings shell. A new workspace-shaped feature copies the workspace shell. Inventing a third shape happens in design review, not in a PR.
---
## 9. Copy and voice
## 10. Copy and voice
Sentence case. "Pair a device", "Danger zone", "Restart daemon", "Inject Paseo tools", "No sessions yet", "Load more". Proper nouns retain casing — Paseo, Beta, Stable, Local. Title case is wrong.
@@ -157,11 +189,11 @@ Terminology:
---
## 10. States
## 11. States
Loading is inline by default. `<LoadingSpinner size={14} color={foregroundMuted} />` sits next to the thing it relates to (`packages/app/src/screens/settings/providers-section.tsx:227-231`). Page-level loading is a centered `<LoadingSpinner size="large">` (`packages/app/src/screens/sessions-screen.tsx:69-72`). Card-level loading is a single short line, not a spinner. In-row dropdown items use `<DropdownMenuItem status="pending" pendingLabel="Removing...">`; the menu item handles its own pending state.
Empty states are short noun phrases. Centered, muted, one or two lines. Sessions screen pairs the empty noun with a single ghost button to navigate back (`packages/app/src/screens/sessions-screen.tsx:74-81`); that pairing is the maximum elaboration. Illustrations and CTAs disguised as emptystates are wrong.
Screen-level empty states use a centered, muted noun phrase and at most one ghost button (`packages/app/src/screens/sessions-screen.tsx`). Sidebar list empty states use a quiet bordered card with a left-aligned `sm` title, a muted `sm` description, and `xs``<Button>` actions. Use `secondary` for the primary action and `outline` for a secondary action (`packages/app/src/components/sidebar/empty-states.tsx`). Keep both forms short and free of illustrations.
Inline errors are a single sentence in `palette.red[300]``xs`, sitting under the field or inside the card it relates to (`packages/app/src/screens/settings/providers-section.tsx:115-119`).
@@ -177,13 +209,15 @@ Partial failure (a list mostly fine but one source errored) is a bordered banner
State surfaces at the smallest scope it affects. Field error stays under the field; page error is a banner; flow-stopping error is an `Alert`.
Changing state must not move the layout. A row that grows when its badge arrives, a card that reflows when a count resolves, a list that jumps as data streams in — all wrong. Reserve the space the loaded state will need, so the skeleton, the spinner, and the content occupy the same box. A surface that shifts under the user stops feeling calm.
---
## 11. List rows
## 12. List rows
The row anatomy is a content column with an optional trailing slot. Inside a card the row is `settingsStyles.row`. Inside a sidebar list the row carries its own padding and `borderRadius.lg` per item (`packages/app/src/components/sidebar-workspace-list.tsx:2614-2625`).
Rows that drill into a detail lead with a chevron in the trailing slot (`ChevronRight`, `iconSize.sm`, `foregroundMuted`). The whole row is the `<Pressable>`. Pair-device row (`packages/app/src/screens/settings/host-page.tsx:644-668`), provider row (`packages/app/src/screens/settings/providers-section.tsx:92-132`), project row in the projects list. Chevron means navigation.
Rows that drill into a detail lead with a chevron in the trailing slot (`ChevronRight`, `iconSize.sm`, `foregroundMuted`). The whole row is the `<Pressable>`. Pair-device row (`packages/app/src/screens/settings/host-page.tsx:644-668`), provider row (`packages/app/src/screens/settings/providers-section.tsx:92-132`), project row in the projects list. A fixed right chevron means navigation. An expandable row uses a trailing right chevron that rotates down when expanded (`SettingsCollapsibleRow`); pressing the row reveals its child rows in place.
Kebab menus (`<DropdownMenu>` with `<MoreVertical size={14} />` trigger) are for actions on the row, not navigation. Trigger style: `padding: 2`, `borderRadius: 4`, hover background `surface2`. Menu position: `align="end"`. Items use `<DropdownMenuItem leading={<Icon size={14} color={foregroundMuted} />} ...>`. Visibility is `isHovered || isTouchPlatform` — hover-revealed on web, always visible on native (`packages/app/src/components/sidebar-workspace-list.tsx:684-770`).
@@ -195,37 +229,44 @@ Selected state on rows in a desktop list+detail uses `surfaceSidebarHover` as th
---
## 12. Status pills and badges
## 13. Status pills and badges
Status pills are `palette.<color>[300]` foreground on a 10%-alpha background of the same color. Success uses green, warning uses amber, danger uses red, muted uses zinc. The `<StatusBadge>` primitive (`packages/app/src/components/ui/status-badge.tsx`) is canonical.
There is exactly one token per status signal — `statusSuccess`, `statusDanger`, `statusWarning`, `statusMerged` — and every status surface uses it: PR state icons, CI check icons and pies, diff stats, file-change icons, status pills, usage bars. A surface does not get a quieter or louder variant because of where it sits. If a dense list feels loud, that is a density or weight problem; fix the density, not the color. The tokens are generated, not hand-picked — see the rule in `packages/app/src/styles/theme.ts` and regenerate rather than nudging one value. The level is set by the densest consumer, the sidebar workspace list.
Status dots — the small filled circles next to a host or agent name — are `borderRadius.full` filled with the status color (`statusSuccess`, `statusWarning`, `statusDanger`, or `foregroundMuted`). They sit in the trailing slot of a sidebar row or as a leading marker on a status pill.
Status **dots** are the one exception, and they are a family of their own — `statusDotSuccess`, `statusDotDanger`, `statusDotWarning`, `statusDotRunning`, read only by `getStatusDotColor` (`packages/app/src/utils/status-dot-color.ts`). Same hues and the same generation rule, but their own band: 90% of gamut chroma against the status family's 55–60%. A dot is a few points of solid color with no shape to read and no label attached, and the running one pulses, so at the status band's chroma the dots read dimmer than the metadata beside them — backwards, since the dot is the row's state. Lightness is set by hue separation rather than by distance from the surface: at 6pt four dark hues on a light surface all read as one dark blob no matter how much contrast they have. So the light band runs as bright as the contrast floor allows at L=0.62, the last step where all four clear 3:1 against the sidebar's `surface2`; the dark band sits at L=0.72, where danger turns pink above. All four move together; regenerate the set, never one hue.
The bespoke pills in `packages/app/src/screens/settings/host-page.tsx:97-116`, `packages/app/src/components/agent-list.tsx:607-632`, and `packages/app/src/components/sidebar-workspace-list.tsx:2889-2894` are drift to be removed. New code uses `<StatusBadge>`.
Status pills use the status token for text on the shared `surface3` and `border` shell. The neutral shell keeps the signal legible without manufacturing translucent colors outside the theme. The `<StatusBadge>` primitive (`packages/app/src/components/ui/status-badge.tsx`) is canonical; a pill never reaches into `palette`.
Status dots — the small filled circles next to a host or agent name — are `borderRadius.full` filled with the status token. Which token a given agent state maps to is owned by `getStatusDotColor` (`packages/app/src/utils/status-dot-color.ts`); a row, a group header, and a project icon all call it rather than restating the mapping. They sit in the trailing slot of a sidebar row or as a leading marker on a status pill.
Identity badges — the project icon, the sidebar host badge, and the PR-panel participant avatar — do not use the theme palette. They draw from the fixed ten-color identity table in `packages/app/src/styles/identity-colors.ts`, whose hexes are held to one contrast band so a color identifies rather than ranks. Project icons and PR avatars use it as a fill with a white letter — that is `identityColor`, one theme-independent hex per identity. Host badges use it as a _foreground_ on both the glyph and the label, which is a different contrast problem that the fill table cannot solve: no single hex clears 4.5:1 against both a near-white and a dark sidebar. Foregrounds therefore come from `identityForeground(name, colorScheme)`, one set per scheme, hue unchanged. That set is generated on the **status family's** lightness and chroma fraction, because a meta row puts a host badge beside a CI check and a diff stat, and two families at different lightness make the brighter one shout. Change the status band and this one changes with it. A host with no color assigned falls back to `foregroundMuted`. The table is theme-independent by design; do not fork it per theme, and do not add hexes to it without recomputing the band.
New status pills use `<StatusBadge>`. Identity, shortcut, and interactive link badges remain separate because color does not encode status there.
---
## 13. Forbidden
## 14. Forbidden
- `fontWeight.medium` on row titles, body text, button labels, badge text, or `<SidebarCallout>` titles. Medium is reserved for the structural-label tier described in §3 — section labels, modal/sheet titles, dense metadata emphasis, and tight action labels. Anything else is `normal`. `<ScreenTitle>` is responsive `400/300` and is never overridden.
- `<Pressable>` wrapping `<Text>` to make a button. `<Button>` exists.
- Bare `<Text>` for a section header inside settings. `<SettingsSection>` exists.
- A muted paragraph between a section header and its card. Section-level explanation is the header's `info` tooltip (§7).
- A "Settings" CTA on a detail page. Detail pages are settings; settings is reached from the sidebar, the host entry, or a row's kebab menu.
- The word "checkout" in UI strings or identifiers. The term is "workspace".
- New color tokens or hardcoded hex outside the palette. Status pill rgba backgrounds are the documented pattern (§12), not a license.
- New color tokens or hardcoded hex outside the palette. The identity color table is the documented exception (§13), not a license.
- Placeholder text dimmed beyond `foregroundMuted`. No extra opacity, no italics, no ghost-text.
- `onPointerEnter` and `onPointerLeave`. They do not fire on native iOS. Hover uses Pressable's `onHoverIn`/`onHoverOut` gated with `isHovered || isCompact || isNative`.
- Raw DOM APIs without an `isWeb` guard.
- Spacing values outside the scale. `padding: 20` and `gap: 10` are wrong.
- Color changes for disabled state. Opacity only.
- Destructive actions without `confirmDialog`. Restart, remove, and future destructive actions are confirmed. Worktree archive is confirmed only when git runtime reports uncommitted changes or unpushed commits; clean pushed worktrees archive immediately.
- Destructive actions without `confirmDialog`. Restart, remove, and future destructive actions are confirmed. Archive workspace is confirmed only when its worktree backing reports uncommitted changes or unpushed commits; otherwise it archives immediately.
- Bespoke status pills. `<StatusBadge>` is the pill primitive.
- Raw `Modal` for a focused task. `<AdaptiveModalSheet>` is the modal primitive.
- Importing `ActivityIndicator` directly. `<LoadingSpinner>` is the loading primitive.
@@ -19,8 +19,29 @@ Root checkout dev is intentionally split across terminals:
- `npm run dev:app` runs Expo on `http://localhost:8081` and connects to the dev daemon.
- `npm run dev:desktop` runs its own Electron-flavored Expo server on the first free port from `8082` through `8089`. It never claims port `8081`.
Desktop dev launches its desktop-managed daemon with `PASEO_NODE_ENV=development`,
so development-only providers such as Mock Load Test are available. Packaged
desktop launches always force the daemon to production mode.
The web and desktop dev launchers pass the current Git branch to Metro as
`EXPO_PUBLIC_PASEO_DEV_BUILD_LABEL`. The expanded desktop sidebar shows it in
the titlebar row. Production builds leave the variable unset and show no label.
`npm run dev` is only a shorthand for `npm run dev:server`. Keep `127.0.0.1:6767` for the packaged app and production-style `~/.paseo` state.
## Nix desktop package
The flake exposes `packages.<system>.desktop` on Linux and macOS:
```bash
nix build .#desktop
```
Linux produces the `paseo-desktop` launcher and desktop entry. macOS produces
`Applications/Paseo.app` plus the `paseo-desktop` launcher. Both use the nixpkgs
Electron runtime and the checkout's built daemon, client, and renderer rather
than downloading a published desktop release.
### PASEO_HOME
`PASEO_HOME` is the directory that holds runtime state (agents, worktrees, workspace config, sockets, daemon log). Resolution rules:
@@ -49,22 +70,88 @@ PASEO_DEV_RESET_HOME=1 npm run dev # clear and reseed the derived wor
In Paseo-managed worktree services, use the injected service environment rather than hardcoded root checkout ports.
### Expo Router
Route ownership, startup restore, and native blank-screen gotchas live in
[expo-router.md](expo-router.md). Read it before changing `packages/app/src/app`,
startup routing, remembered workspace restore, or active workspace selection.
### iOS simulator preview service
Paseo worktrees expose the native iOS dev app through the `ios-simulator` service in `paseo.json`. The service URL serves the simulator preview at `/.sim`, so the preview link is `${PASEO_URL}/.sim`.
**Prerequisites (macOS only).** The service shells out to the Apple toolchain, so beyond the `npm ci` that worktree setup runs you must install:
- **Xcode** (the full app, not just the Command Line Tools) — install it from the Mac App Store, or from `developer.apple.com/download` for a specific version. It provides `xcodebuild` and `xcrun simctl`; accept its license and let first-run component installation finish before starting the service.
- **An iOS Simulator runtime with at least one iPhone device type**. Recent Xcode versions may not bundle a runtime — add one via Xcode → Settings → Components (older Xcode: "Platforms"). The service targets `iPhone 16 Pro` by default (override with `PASEO_IOS_DEVICE_TYPE`) and falls back to any iPhone; it fails with `No iPhone simulator device type is installed` when none exist.
- **Homebrew** — CocoaPods itself installs automatically: `expo prebuild` runs `pod install` on a cold worktree, and when the CocoaPods CLI is missing the runner installs it for you. It tries `gem install cocoapods` first and falls back to Homebrew (`brew install cocoapods`), so having Homebrew available lets that fallback succeed without a manual step.
`serve-sim`, Expo, and Metro come from `npm ci`, and CocoaPods installs itself on the first prebuild as described above.
The service is designed for concurrent worktrees: it derives a deterministic simulator identity from the worktree path, uses the worktree's assigned `PASEO_PORT`, pins `serve-sim` to that simulator UDID, and only tears down that worktree's helper/simulator state. It must not rely on the globally booted simulator or any fixed Metro port.
Worktree setup best-effort seeds the generated iOS project and newest native build cache from the source checkout before the service runs. The service still validates the native project by running Expo prebuild and Xcode; the seed only avoids paying all setup/build cost from a cold worktree every time.
Starting the service must not create, focus, reveal, or leave behind macOS Simulator.app windows. The browser preview is the user-visible simulator surface.
Starting the service must not create, focus, reveal, or leave behind macOS Simulator.app windows — a guard hides Simulator.app every 250ms, so the native window vanishes if you focus it. The user-visible surface is the interactive `/.sim` preview: a `serve-sim` stream (60 FPS MJPEG + a WebSocket control channel) that Metro mounts at `basePath: "/.sim"` (`packages/app/metro.config.cjs`) and that forwards taps and gestures, so first-launch prompts like "Open in PaseoDebug?" are answered there, not in the native window. Open the `${PASEO_URL}/.sim` link the service prints — not `serve-sim`'s raw stream port (`:3100`), which is view-only. Because the stream sits behind the daemon proxy it is convenient for remote viewing but laggy up close; for fast local dev at the Mac, use the native simulator path below.
**Troubleshooting.** If `xcrun simctl` fails with `unable to find utility "simctl"`, the active developer directory is still the Command Line Tools even though Xcode is installed. Point it at Xcode: `sudo xcode-select -s /Applications/Xcode.app/Contents/Developer`, then confirm with `xcrun --find simctl`.
### Running the iOS app on a local simulator
For fast, native, interactive iOS dev at the Mac — as opposed to the remote `/.sim` preview above — skip the service and build the dev client directly:
```bash
npm run ios # → expo run:ios (packages/app): builds and launches the app in the real Simulator.app
```
`expo run:ios` starts its own Metro and gives you the normal Simulator.app window (full speed, native touch, no stream).
**Pointing the app at a daemon.** The client resolves its local daemon from `EXPO_PUBLIC_LOCAL_DAEMON` (`packages/app/src/runtime/host-runtime.ts`); when unset it falls back to `localhost:6767`, the production `~/.paseo` daemon. To target a worktree's dev daemon instead, set it on the build command:
```bash
EXPO_PUBLIC_LOCAL_DAEMON=localhost:${PASEO_SERVICE_DAEMON_PORT} npm run ios # worktree daemon running as a Paseo service
EXPO_PUBLIC_LOCAL_DAEMON=localhost:6768 npm run ios # standalone `npm run dev:server`
```
The iOS simulator shares the Mac's loopback, so `localhost:<port>` reaches the host daemon directly.
**Gotcha — `EXPO_PUBLIC_*` is inlined into the JS bundle at Metro bundle time, not read at runtime.** Set it in the same shell that starts Metro. If the app still connects to the old daemon, Metro served a cached bundle; re-bundle clean with `cd packages/app && EXPO_PUBLIC_LOCAL_DAEMON=… npx expo start -c` and reload the app.
### Desktop renderer profiling
`npm run dev:desktop` starts Electron with Chromium remote debugging enabled on
`http://127.0.0.1:9223` so renderer CPU profiles can be captured through CDP.
It launches its own Electron-flavored Expo server and passes that URL to Electron.
Override the CDP port with `PASEO_ELECTRON_REMOTE_DEBUGGING_PORT` when `9223` is busy.
`npm run dev:desktop` starts Electron with Chromium remote debugging enabled so
renderer CPU profiles can be captured through CDP. By default it passes
`--remote-debugging-port=0`, so Chromium atomically asks the OS for an available
port and prints the selected DevTools endpoint. Set
`PASEO_ELECTRON_REMOTE_DEBUGGING_PORT` when a QA workflow requires a validated,
fixed port.
Desktop dev also scopes Electron `userData` to the current dev root. This prevents
desktop-only environment inherited by terminals opened inside Paseo from coupling
a new worktree instance to the parent desktop instance's profile or single-instance
lock.
The desktop workspace script `exec`s the dev runner so the terminal owns the runner
PID. Terminal shutdown reaches the runner as `SIGHUP`; the runner stops Metro and
asks Electron to quit through its normal app lifecycle. Do not add an npm wrapper or
detach Electron: either change leaves an orphan holding the worktree's single-instance
lock and broken output pipes.
With desktop dev running, verify the real BrowserWindow, titlebar clearance, fullscreen
transition, and 751-pixel settings split with:
```bash
npm run verify:electron-cdp --workspace=@getpaseo/desktop
```
The verifier reads the same `EXPO_PORT` and
`PASEO_ELECTRON_REMOTE_DEBUGGING_PORT` environment names as desktop dev. Set an
explicit remote-debugging port for verifier runs, and set both when testing an
isolated instance on non-default ports.
When running a dedicated Electron QA instance against a non-default Expo port, set
`EXPO_DEV_URL` explicitly. Desktop main defaults to `http://localhost:8081`, so
`PASEO_PORT=57928` alone starts Metro on 57928 but Electron still loads 8081.
### React render profiling
@@ -130,6 +217,78 @@ PASEO_PROFILE_IDLE_WAIT_MS=3000 # idle baseline before switching
PASEO_PROFILE_DUMP_COMMITS=1 # include per-commit profiler samples
```
For warm workspace switching, point the benchmark at an app backed by seeded
daemon state:
```bash
PASEO_PROFILE_APP_URL=http://localhost:19010 \
npm run profile:workspace-switching --workspace=@getpaseo/app
```
The benchmark first warms `Cmd+1` through `Cmd+7`, then records a rapid seven-workspace
burst. It separately warms the three-entry workspace deck LRU and records `Cmd+1` through
`Cmd+3` without waits between keys. Both scenarios report keydown-to-activation latency and
React commits on the same browser clock. Set `PASEO_PROFILE_WORKSPACE_DIGITS`,
`PASEO_PROFILE_WORKSPACE_LRU_SIZE`, or `PASEO_PROFILE_WARM_QUIET_MS` to change the shape. Set
`PASEO_PROFILE_DUMP_COMMITS=1` to include the nested component breakdown for every commit.
Set `PASEO_PROFILE_RETAINED_REPEATS=5` to repeat the retained-LRU burst for a less noisy sample.
Set `PASEO_PROFILE_CPU_PATH=/tmp/workspace-switch.cpuprofile` to run a separate retained-LRU
capture after the latency scenarios and write a raw CDP CPU profile without contaminating their
timings.
Set `PASEO_PROFILE_TRACE_PATH=/tmp/workspace-switch.trace.json` to run another separate retained-LRU
capture and write a Chromium Performance trace with User Timing marks and screenshots. Open the
trace in the Chrome DevTools Performance panel. Set `PASEO_PROFILE_TRACE_INVALIDATIONS=1` only for a
focused invalidation capture; React Native's generated stacks make that mode highly intrusive.
Set `PASEO_PROFILE_TRACE_FOCUS=1` to include focus targets, durations, and JavaScript call stacks in
the scenario report. This mode wraps `HTMLElement.focus`, so use it only for diagnosis.
For the desktop Explorer sidebar toggle, run the app against the root checkout's daemon and use:
```bash
npm run profile:explorer-toggle --workspace=@getpaseo/app
```
The harness verifies port `6768`, opens the Paseo workspace, creates and warms the Explorer pane,
records an idle control, then measures settled and 50 ms burst Cmd+E toggles. It reports
input-to-DOM and input-to-paint latency, React commits, mounts, unmounts, and DOM mutations. Set
`PASEO_PROFILE_TRACE_PATH=/tmp/explorer-toggle.trace.json` or
`PASEO_PROFILE_CPU_PATH=/tmp/explorer-toggle.cpuprofile` for separate Chromium captures. Override
the app URL, daemon port, workspace, or server with the corresponding `PASEO_PROFILE_*` variables.
For sustained composer typing, run the paired composer-versus-textarea benchmark against a seeded
daemon:
```bash
PASEO_PROFILE_APP_URL=http://localhost:19010 \
npm run profile:composer-typing --workspace=@getpaseo/app
```
The benchmark opens the first workspace, preserves its existing draft, and dispatches 300 printable
keys at a fixed 16 ms cadence without waiting for each key to paint. It measures renderer
`keydown` to the next paint opportunity, verifies that every character survived, alternates the
composer and plain-textarea control across three runs, then restores the original draft. The report
includes percentiles, frame coalescing, input processing, React work, long tasks, slow samples, and
Playwright dispatch lateness. Set `PASEO_PROFILE_TYPING_KEYS`, `PASEO_PROFILE_TYPING_CADENCE_MS`,
`PASEO_PROFILE_TYPING_REPEATS`, or `PASEO_PROFILE_WORKSPACE_DIGIT` to change the scenario. Optional
`PASEO_PROFILE_CPU_PATH` and `PASEO_PROFILE_TRACE_PATH` captures run separately after the latency
measurements so profiling overhead does not contaminate them.
Set `PASEO_PROFILE_TYPING_SCENARIO=height-growth` to alternate `Shift+Enter` and printable input.
That report includes input and composer height changes plus React work grouped into composer,
stream, and ancestor/root scopes. Ancestor/root timings include descendant work because the Profiler
boundaries are nested. A printable key after an empty newline should not change either height.
### Preview Windows and Linux window controls on macOS
Desktop development can replace native macOS traffic lights with Paseo's custom controls:
```bash
PASEO_DESKTOP_WINDOW_CONTROLS=windows npm run dev:desktop
PASEO_DESKTOP_WINDOW_CONTROLS=linux npm run dev:desktop
```
The override is rejected in packaged builds. Restart the desktop process when changing it.
### Desktop macOS compositor watchdog
macOS display sleep can leave Chromium's GPU-process display link — the vsync
@@ -152,6 +311,22 @@ frames non-stop, pinning ProMotion displays at 120Hz forever and draining the
battery while the app is idle — so do not re-add it. The probe's visibility
guards already prevent throttling from causing a false stall.
### Desktop Linux hidden-window fallback
On Wayland (seen on ChromeOS Linux) a window created with `show: false` can sit
forever without a single compositor frame: the renderer loads and runs, but
`ready-to-show` never fires, so the window is never shown and the app looks like
it did not start while every process is alive. `showWindowWhenReady`
(`packages/desktop/src/window/show-when-ready.ts`) shows the main window when
the document finishes loading if the event has not arrived. The fallback is
Linux-only; macOS and Windows keep waiting for `ready-to-show`.
Run the startup regression on Linux with an isolated display:
```bash
xvfb-run -a npm run test:e2e:window-startup --workspace=@getpaseo/desktop
```
### Daemon logs
Check `$PASEO_HOME/daemon.log` for daemon logs. The default level is `info`; set
@@ -163,11 +338,77 @@ The supervisor rotates `daemon.log`. Persisted `log.file.rotate` settings in
`PASEO_LOG_ROTATE_SIZE` and `PASEO_LOG_ROTATE_COUNT` env vars override the
defaults. The default rotation is `10m` x `3` files everywhere.
### Git process pressure
If Git refreshes consume too much CPU, disk, or antivirus capacity, especially on Windows, reduce
the daemon-global Git process limits in `$PASEO_HOME/config.json`:
```json
{
"daemon": {
"git": {
"maxProcessesPerSecond": 5,
"maxProcessConcurrency": 4
}
}
}
```
Reload the daemon with `paseo reload`. Environment-variable overrides still require a restart because
the launch environment remains authoritative. Lower values reduce machine pressure but make Git-backed workspace state and
Git RPCs wait longer. See [Git process limits](data-model.md#git-process-limits) for defaults,
semantics, and environment-variable overrides.
### Agent Tool Catalog Measurement
Measure the MCP `tools/list` payload that Paseo injects into agents with:
```bash
npm run measure:agent-tools --workspace=@getpaseo/server
```
The command reports compact JSON bytes, estimated tokens, field totals, largest
tools, and the browser-tools delta. It defaults to the agent-scoped catalog; use
`-- --scope=top-level` for the unaffiliated `/mcp/agents` shape and `-- --json`
for machine-readable output.
## Worktree starting refs
A new worktree starts from the current branch's upstream, or the local branch when it has no
upstream. This keeps unpushed local commits out of new workspaces by default. The picker collapses
identical refs; divergent local or non-origin refs remain explicit, qualified choices.
The daemon sends the exact upstream ref because the remote and branch names cannot be inferred.
Worktrees retain that ref for comparisons and updates from base while exposing its branch name to
the UI. Merging into base requires a mutable local target: `origin/main` maps to local `main`, while
another remote fails closed until the worktree records an explicit local target. Older daemons omit
the optional field and retain the previous local-first behavior; older worktree metadata without the
exact ref also resolves through its stored branch name.
Worktrees inherit committed Git state only; uncommitted source-checkout changes are not copied.
## paseo.json service scripts
`worktree.setup` and `worktree.teardown` accept either a multiline shell script or an array
of commands. Both run sequentially.
Lifecycle commands run in the worktree through a stable script shell: `bash`
resolved from `PATH` on macOS/Linux, and PowerShell with `-NoProfile` on
Windows. They inherit the daemon environment plus Paseo's lifecycle variables;
login and interactive shell startup files are not loaded, and Bash's `BASH_ENV`
hook is unset. ACP single-string terminal commands use the same non-login Bash
behavior on macOS/Linux, but preserve their existing `cmd.exe /c` string semantics
on Windows. Service scripts are separate:
they launch in a terminal and receive the service environment described below.
Because the shell differs per platform, a lifecycle command that must run
expansion, `cp`/`rm`, or a `./scripts/*.sh` entrypoint all fail under PowerShell,
and `bash` is not guaranteed to exist on Windows. Put that logic in a Node script
that reads what it needs from `process.env` and invoke it as
`node ./scripts/<name>.mjs`. This repo's own setup does exactly that in
`scripts/seed-worktree-dev-state.mjs` and `scripts/seed-ios-native-cache.mjs`.
```json
{
"worktree": {
@@ -204,6 +445,66 @@ Service proxy hostnames use the double-dash shape: `web--feature-auth--project.l
}
```
Service ports use OS ephemeral allocation by default. Set `worktrees.servicePorts` in
`$PASEO_HOME/config.json`, or replace it for one project with `worktree.servicePorts` in
`paseo.json`. The block accepts an inclusive `range` such as `"3000-4000"` or a `portScript`
executable. Since `portScript` is executed directly without a shell, it must point to a real executable (e.g., a binary or a script with a proper shebang like `#!/bin/sh`) rather than an inline shell command or shell pipeline. For inline shell commands or pipelines, wrap them in a small script. `portScript` runs in the workspace directory with four arguments: service name,
workspace ID, branch name, and worktree path. A missing branch is passed as an empty string. The same
values are available as `PASEO_SCRIPTNAME`, `PASEO_WORKSPACE_ID`, `PASEO_BRANCH_NAME`, and
`PASEO_WORKTREE_PATH`. The script must print one valid TCP port. Paseo trusts the external allocator,
so the port may already be bound. `portScript` takes precedence when both values are present.
## Bundled daemon web UI
> The user-facing guide for this feature (enabling it, reverse proxy, TLS, tunnels, security) lives at [public-docs/web-ui.md](../public-docs/web-ui.md). This section is the contributor/build reference: how the artifact is produced, bundled, and excluded from desktop packaging.
The daemon can optionally serve the browser web client from the same HTTP server. This is disabled by default.
Enable it in persistent configuration:
```bash
paseo daemon config set features.webUi.enabled true
paseo daemon start
```
Or set the environment variable:
```bash
PASEO_WEB_UI_ENABLED=true paseo daemon run
```
Or persist it in `config.json`:
```json
{
"features": {
"webUi": {
"enabled": true
}
}
}
```
When enabled, opening the daemon HTTP origin (for example `http://localhost:6767/`) serves the web app. The same HTTP server continues to serve `/api/*`, `/mcp/*`, `/public/*`, the WebSocket upgrade, and service-proxy routes. Static files load without daemon bearer auth; API and WebSocket calls still enforce auth.
The served app auto-bootstraps a connection to the same origin, so opening `http://localhost:6767/` directly usually skips the Add Host step.
Build the artifact for packaging or measurement with:
```bash
npm run build:daemon-web-ui
```
This exports the normal browser web app (not the Electron-flavored desktop renderer) and copies it into `packages/server/dist/server/web-ui`, precompressing `.html`, `.js`, `.css`, and JSON assets as `.br` and `.gz`.
Measured bundle size for a standard Expo web export:
- raw: 10.77 MiB
- gzip: 2.55 MiB
- brotli: 1.93 MiB
The desktop-managed daemon disables the bundled web UI by default (`PASEO_WEB_UI_ENABLED=false`) because the desktop app already ships the renderer as `app-dist`. Shipping the same assets again inside `@getpaseo/server` would duplicate the ~10.8 MiB install. Desktop packaging also excludes `node_modules/@getpaseo/server/dist/server/web-ui/**` from the packaged app.
## Built workspace packages
Package imports resolve through package exports to compiled `dist/` output, not sibling `src/` files. This is true in local dev and in published packages: the app, daemon, CLI, and SDK consumers should all exercise the same runtime paths.
Use `npm run build:server` whenever you have changed any daemon/server-facing package and need clean cross-package types or runtime behavior.
The app Metro config disables Watchman and uses Metro's node crawler for exports. Keep that invariant unless you have verified production app exports on machines with and without Watchman installed; distro Watchman builds can differ in capabilities and change Metro's crawl behavior.
For tighter loops, you can rebuild a single workspace:
- Changed `packages/protocol/src/*` or `packages/client/src/*`: `npm run build:client`.
- Changed `packages/server/src/*`, `packages/cli/src/*`, `packages/relay/src/*`, or `packages/highlight/src/*`: `npm run build:server`.
- Changed app build dependencies: `npm run build:app-deps`.
## Dependency patches
`patches/*.patch` are applied by `scripts/postinstall-patches.mjs` on every install. A patch only
runs when its package is actually present, so add the package to that script's `patchedPackages`
list when you introduce a new patch — otherwise the file sits in `patches/` and never applies.
Regenerate a patch with `npx patch-package <package>` after editing `node_modules/<package>`, and
patch every build the consumers use: Metro resolves the `react-native` field of a package
(`src/*.ts` for `react-native-svg`), while Node and Vitest resolve `main`/`module`
(`lib/commonjs`, `lib/module`). Patching only `lib/` leaves the app bundle unfixed.
Use `npm run cli` to run the in-repo CLI from source (`npx tsx packages/cli/src/index.ts`). The script wraps the CLI with `scripts/dev-home.sh`, so it automatically uses this checkout's `.dev/paseo-home` and dev daemon endpoint unless you pass an explicit override. The globally installed `paseo` binary on macOS is a symlink into the installed Paseo desktop app, not this checkout — use it to drive the desktop's built-in daemon, but use `npm run cli` when you want to talk to the CLI you are editing.
Canonical automation uses `paseo project create/ls/rename/delete`, `paseo workspace create/ls/rename/archive`, `paseo heartbeat create/update/delete`, and the full `paseo schedule` group. MCP heartbeat automation is intentionally smaller: create and delete only. Detach remains an explicit user lifecycle action rather than an agent tool. `paseo run --new-workspace local|worktree` composes workspace creation with agent creation. The old `paseo worktree` and `paseo run --worktree` forms are hidden compatibility aliases.
```bash
npm run cli -- ls -a -g # List all agents globally
npm run cli -- ls -a -g --json # Same, as JSON
npm run cli -- inspect <id> # Show detailed agent info
npm run cli -- logs <id> # View agent timeline
npm run cli -- agent open <id> # Focus an existing agent in Paseo Desktop
npm run cli -- daemon status # Check daemon status
npm run cli -- clone owner/repo --dir ~/workspace # Clone GitHub repo and register project
```
Use `--host <host:port>` to point the CLI at a different daemon:
Use the global `--host` option to point the CLI at a different daemon:
```bash
npm run cli -- --host localhost:7777 ls -a
npm run cli -- --host ssh://user@host ls -a
```
Set `PASEO_HOST` to use the same target across invocations. An explicit
selector overrides both environment selectors. With both `PASEO_HOME` and `PASEO_HOST` set, pass an explicit selector. See [CLI target selection](../public-docs/cli.md#select-one-daemon).
In an SSH URI, the URL port is the SSH server port. The remote daemon defaults to `127.0.0.1:6767`; use `?daemonPort=7777` to override it. The transport runs non-interactively through the local OpenSSH client and never installs, starts, or configures the remote daemon. User-facing setup and troubleshooting live in [public-docs/connectivity.md](../public-docs/connectivity.md#ssh).
Desktop integrations can focus an existing agent without creating one or
sending a message. Use `paseo://h/<server-id>/agent/<agent-id>`, or run
`paseo agent open <agent-id>`. The CLI reads the local daemon's server ID by
default; pass `--server <server-id>` when targeting another server.
Paseo publishes a container image for running the daemon on a server, VM, NAS,
or homelab box. The image also serves the bundled browser web UI, so one
container gives you both the daemon API and a self-hosted UI.
The image source lives in [`docker/`](../docker/).
## How it works
The official image:
- builds `@getpaseo/server` and `@getpaseo/cli` from source-built workspace tarballs
- runs the daemon as the non-root `paseo` user
- listens on `0.0.0.0:6767` inside the container
- enables the bundled daemon web UI with `PASEO_WEB_UI_ENABLED=true`
- stores daemon state and agent credentials under `/home/paseo`
- leaves agent CLIs out of the base image
Open the container's HTTP origin, for example `http://localhost:6767`, to load
the web UI. The served app receives a same-origin connection hint and connects
back to that daemon. Static UI files load without daemon auth; API and
WebSocket requests still require `PASEO_PASSWORD` when one is configured.
Host-side CLI commands select the container explicitly, for example `paseo project ls --host 127.0.0.1:6767`. Without an endpoint selector the CLI looks for a local home’s supervisor. Container environment settings are deployment overrides; worker restart preserves them. Your container manager owns full supervisor replacement.
## Quick Start
```bash
docker run -d --name paseo \
-p 6767:6767 \
-e PASEO_PASSWORD=change-me \
-v "$PWD/paseo-home:/home/paseo" \
-v "$PWD:/workspace" \
ghcr.io/getpaseo/paseo:latest
```
Then open:
```text
http://localhost:6767
```
If you set `PASEO_PASSWORD`, enter the same password when adding the direct
daemon connection in the web UI or another Paseo client.
## Docker Compose
Use [`docker/docker-compose.example.yml`](../docker/docker-compose.example.yml):
A sheet holding unsaved work is never implicitly displaced; only its guarded close removes it.
Moving its tab to another host remounts the panel, and the file editor keeps unsaved text in the
mounted view, so the move would drop it. Widening the layout is the known exception: it still moves
the sheet's tab into a pane, and edits the autosave has not started writing are lost.
## Explorer sidebar
`packages/app/src/workspace-tabs/explorer-sidebar.ts` owns show, hide, toggle, and view selection.
On desktop, the shell is rendered outside the workspace split canvas so it divides the full
workspace, including the header. It has its own persisted width and resize handle. Main-pane splits
never read or modify that width.
`packages/app/src/workspace-tabs/open-supporting-view.ts` owns semantic Changes and pull-request
opens. Compact and wide native layouts select the matching Explorer tab. Desktop Changes opens
follow the shared diff preference. Desktop pull requests use their Main panel, On the side, or
Explorer sidebar setting. Automatic PR discovery follows that preference once per workspace without
interrupting the user's work. Closing the tab opts that workspace out of future automatic opens,
even for a different PR; moving or reordering it remains the user's choice.
Callers request the content and never choose the shell.
The composer Changes pill is a two-stage desktop action: it first reveals Explorer on Changes, then
routes later presses to the working diff through the shared diff preference.
The persisted layout still contains the Explorer pane so tabs survive reloads. The renderer removes
that pane from the workspace split tree and docks it separately. Persisted identifiers retain the
literal `"explorer"` pane id and `explorerPaneIdByWorkspace` key for compatibility.
Explorer uses the shared workspace tab row and ordinary tab context menus. Files and Changes
hide their close buttons through the panel manifest; close them from the tab context menu.
Other tabs reveal the close control on hover. The + menu opens compatible panels in the dock and
omits Agent and terminal profiles. Agents and terminals can still be dragged into Explorer.
Bulk-close actions apply only to the dock's tabs. Explorer tabs can be reordered and dragged
between compatible panes, but the dock cannot be split or maximized. Selecting an Explorer tab
does not change workspace focus.
Cmd+E shows or hides Explorer without changing its selected view. Compact layouts use the combined
full-screen Explorer overlay for Changes, Files, and pull requests, and close it after a file opens. Compact Changes has no tree rail; its overview is the Jump to file action (`packages/app/src/git/jump-to-file/`), a sheet over the same changed-files tree the desktop rail renders.
Wide native layouts without pane splits use the same combined content in a resizable inline dock;
opening a file leaves that dock visible. Both presentations keep their selection in the panel store
and reuse the layout store's per-workspace Explorer width. They do not create a second Explorer
lifecycle.
## Side pane
`packages/app/src/workspace-tabs/open-beside.ts` owns content opened beside the user's work. The
layout store remembers one ordinary pane per workspace. The first side open creates a full-height
right split around the workspace root; later side opens reuse it. Only the `split-panes` layout
mode opens beside. Compact and single-pane layouts (touch tablets) open the same target in the main
pane, so callers never check whether splits render.
Removing a pane clears its remembered id; a later side open creates a new pane. The last visible
ordinary pane stays when its final tab closes and shows the New launcher. An empty workspace does
not automatically create an agent draft tab; choosing Agent opens one. Explorer cannot replace the
workspace canvas, even when visible. Restoring a saved layout enforces the same rule while preserving Explorer and saved
tab content. There is no hidden side-pane lifecycle.
The file explorer uses colored SVG icons from [`material-icon-theme`](https://github.com/material-extensions/vscode-material-icon-theme) (installed as a dev dependency in `packages/app`).
Use `packages/server/src/server/file-observer` for recursive filesystem observation. Create one observer service at the owning service boundary, subscribe roots through it, and close it during owner shutdown. Keep platform watchers, directory discovery, exclusion reconciliation, event batching, failure cleanup, diagnostics, and teardown inside the module. A consumer supplies a root and excluded subtrees; it must not branch on the platform or manage child watchers.
Do not replace the Linux implementation with `fs.watch({ recursive: true })`. Node 22 implements Linux recursion in JavaScript by walking the tree and watching every file and directory. Paseo's directory-only Linux watcher already exhausted resources before ignored-tree pruning and a watcher cap were added in [#794](https://github.com/getpaseo/paseo/pull/794).
Closing an observation must remain safe after its root is renamed or removed. Workspace archive removes owned worktrees while releasing observation references, so teardown cannot assume the watched path still exists.
`unsubscribe()` is an awaited barrier. Once it resolves, no scan, reconciliation, queued event, native handle, or callback may remain. `updateIgnore()` is the equivalent barrier for excluded paths: events queued below a newly excluded root cannot be delivered after it resolves. Treat path disappearance while scanning, attaching, or receiving a watcher error as rename/archive churn. Surface permission and resource errors so WorkspaceGit can enter bounded polling.
Linux topology repair is trailing-debounced and scoped to the renamed path. A file rename needs one classification stat; a new or moved directory scans only its subtree. Full-tree scans are reserved for startup and ignore-set replacement. The initial Linux scan conservatively emits existing files discovered before their directory watcher was installed; WorkspaceGit batches these into one refresh. Benchmark this startup cost with pre-populated trees.
Windows and macOS use one native recursive watcher plus an ignored-pruned, directory-indexed inventory. Native events deliver immediately. Named events trigger serialized shallow scans of their parent directories; known directory events also reconcile that subtree. This recovers concrete create and delete paths omitted by coalescing without walking the whole root for each edit. Classification is admission-controlled to 32 concurrent `fs.stat` calls, since `fs.stat` runs on the libuv threadpool the whole daemon shares. Startup and exclusion changes use an awaited full inventory barrier. Activity arms a full safety audit 30 seconds after it settles, with a 30-second minimum interval and a five-minute starvation bound; pathless native events also trigger an immediate shallow root scan. Observation fails into polling rather than retaining more than 250,000 file and directory entries per root. A repository that large is also expensive to poll, so degraded polling doubles its gap from 5 seconds to a 60-second ceiling while the workspace snapshot comes back unchanged, and resets to 5 seconds on the first change. Explicit reads still force a refresh; the ceiling only bounds how long an edit made outside Paseo stays undiscovered.
Read aggregate health from the owning observer service. Runtime metrics include active observations, native handles, tracked native files, pending events and immediate reconciliation work, scoped and full reconciliation activity and latency, and failure counts. `nativeTrackedFileCount` and other per-file counters are native-backend diagnostics only — the Linux backend keeps no per-file inventory and reports zero — so a cross-platform assertion belongs on delivered events, not those counters. Delayed safety audits do not count as pending work. Closing the service releases every subscription and clears its diagnostics. Do not add path lists, watcher handles, or platform-specific controls to this interface.
Git owns Git-ignore evaluation. The observer accepts absolute excluded roots and applies updates without replacing the observation or exposing its watcher topology. This keeps tracked files inside otherwise ignored directories observable and keeps Git policy out of the filesystem module.
Workspace Git verifies each repository metadata subscription with a one-shot canary inside the Git directory. If the event does not round-trip through the subscription callback, treat the watcher as unavailable and enter degraded polling. Refresh working-tree Git-ignore exclusions from ignore-file events, newly observed directories, and watcher recovery, never from a healthy-watcher timer. `git ls-files` reports an ignored directory only once it exists, so new directory topology is a trigger in its own right; the refresh is edge-triggered per directory and debounced so a dependency install collapses into a couple of Git runs.
The real-filesystem contracts and daemon auto-archive lifecycle run in the normal server test suite. Use the scripts only for manual performance and soak work: `npm run measure:file-observer --workspace=@getpaseo/server` measures burst and sustained-create behavior, and `npm run repro:file-observer-teardown --workspace=@getpaseo/server` runs the teardown soak.
focus when it closes. Hover cards register with `manageFocus={false}`: they claim overlay keys without moving or trapping focus on pointer hover. Do not add component-local global Escape listeners: two
stacked overlays would both close on one keypress.
If an overlay is rendered by a global host rather than beneath its opener in
the React tree, carry the opener's current layer through the host store and
restore it with `OverlayLayerProvider`. Otherwise painting and keyboard
ownership silently reset at the app root. When the opener is a global keyboard
action and has no component context to carry, resolve the host layer with
`useGlobalWebOverlayLayer` on its closed-to-open transition. It captures the
current top registered layer before the new host joins the stack; do not give a
global dialog a fixed root-derived modal layer.
## Gotcha 2 — Portal breaks lifecycle and coordinate-system inheritance
@@ -60,12 +115,11 @@ quietly relying on:
Gate `visible` on a screen-focus signal. For panes inside `agent-panel`, the
`isPaneFocused` prop already exists and flips on pane switches; pass
`visible={isYourOwnVisible && isPaneFocused}`.
- **Transforms.**The composer is wrapped in a Reanimated `Animated.View` with
`translateY: -keyboardShift` (see`use-keyboard-shift-style.ts`). The chat
content has the same transform applied (`agent-panel.tsx:939`). They move
together because they share the SharedValue. A portal'd popover is outside
the composer tree — it does not get that transform unless you apply it
yourself.
- **Transforms.**`KeyboardShiftProvider` owns the canonical keyboard shift
SharedValue, and`useKeyboardShiftStyle()` only adapts that value into
translate/padding styles. The composer and chat content must both read that
provider-owned value. A portal'd popover is outside the composer tree — it
does not get that transform unless you apply it yourself.
- **Layering.** The default root host renders after app content, so it sits
above compact sidebars. Content overlays that must sit below sidebars should
use the current `FloatingPanelPortalHost`.
@@ -73,33 +127,107 @@ quietly relying on:
renders inside its host, not necessarily at window origin. Position anchored
content relative to the host: `anchorRect - hostRect`. This is what
`measureFloatingPanelPortalHost()` is for.
- **React context.**`@gorhom/portal` is not a React portal — a real one keeps
context, this one does not. It stores the element and the host renders it, so
context resolves at the _host's_ position. Everything provided between
`PortalProvider` in `app/_layout.tsx` and your sheet is invisible inside it.
This is why app-wide providers wrap `PortalProvider` rather than the reverse.
The fix for transforms is Gotcha 3.
The fix for transforms is Gotcha 3. The fix for context is Gotcha 7.
## Gotcha 3 — Reanimated transforms vs `measureInWindow`
## Gotcha 3 — Keyboard layout and portal anchors
`measureInWindow` returns the view's _current_ screen position. In theory that
includes Reanimated-applied transforms (Reanimated updates native view
properties, and Android's `getLocationInWindow` reads transformed coords). In
practice it's racy — the measurement may snapshot mid-animation, and on Android
with Reanimated worklets the result is not always stable.
`composer/dock` owns the stationary viewport, bottom-anchored surface, composer
capacity, and keyboard motion for chat, workspace draft tabs, and New workspace.
Render `<ComposerDock>{contentAbove}{composer}</ComposerDock>` below the header.
The optional third child is an overlay that moves with the surface. New workspace
uses `centered` for its existing desktop/tablet form. Hosts never reserve keyboard
space or translate the composer themselves. Put the dismiss surface behind content
as a sibling, with `pointerEvents="box-none"` on its content wrapper. A Pressable
ancestor takes the JS responder on idle Android Fabric and intercepts the stream's
MOVE events before its native scroll view reaches touch slop. Streaming can hide
this failure by delaying the responder grant. Native scroll views retain their own
tap-to-dismiss behavior; unclaimed empty-space taps reach the dismiss sibling.
On iOS, decorative form containers also need `box-none` (and inert titles/spacers
need `none`); otherwise their empty padding blocks hit testing of that sibling.
The shared screen header uses the same dismiss surface. Keep the
header escape available when a capped draft clips all content above it, since
iPhone has no system keyboard-dismiss button. Child controls keep their touches,
and the stream keeps its existing flick-to-dismiss gesture.
If the panel cannot stay inside the transformed ancestor, do not try to track
the keyboard by re-measuring on every frame. Instead,
**slave the popover's transform to the same SharedValue the composer uses**:
The dock's private viewport and capacity model are extracted from chat. The
app-wide `@/keyboard/shift` module owns shared motion for the dock, portals, streams,
terminal, explorer, and dialogs. Import only its public entry point: the provider,
shift hooks, shift style, translate view, settled stream inset, and explorer padding
policy. Its contexts and reconciliation stay private. Keep `KeyboardShiftProvider`
at its root mount point outside the portal host when changing dock hosts.
1. Snapshot `openShift = shift.value` at the moment you measure the anchor.
- **Project** — Logical grouping of workspaces sharing a git remote (or main repo root). UI: "Project" / "Add project". Code:`ProjectSummary` (`packages/app/src/utils/projects.ts:22`), `projectKey` (`packages/server/src/server/workspace-registry-model.ts:16`). Forbidden: "Repo", "Repository" as UI label.
- **Workspace** — One concrete `cwd` on one daemon, with git state; belongs to exactly one project. UI: "Workspace". Code: `WorkspaceDescriptorPayload` (`packages/protocol/src/messages.ts:2178`). Don't confuse with: Branch (one branch can back many workspaces via worktrees). Forbidden: "Folder", "Directory" as UI label.
- **Project** — A stable, exact selected-root record. Its host-local `projectId` is an opaque `prj_<16 hex>` value. Its persisted`projectKey` is an opaque equivalence key that may group the logical project across hosts. A normalized Git remote is the current key producer, but consumers must not parse or rederive it. Git facts can update mutable kind and grouping metadata but never the ID, root, or default display name. UI: "Project" / "Add project". Forbidden: "Repo", "Repository" as UI label.
- **Workspace** — One concrete `cwd` on one daemon, with git state; belongs to exactly one project. Its `id` is opaque workspace identity; its `cwd` is the filesystem directory. UI: "Workspace". Code: `WorkspaceDescriptorPayload` (`packages/protocol/src/messages.ts:2178`). Don't confuse with: Branch (one branch can back many workspaces via worktrees). Forbidden: "Folder", "Directory" as UI label.
- **Archive workspace** — Removes one workspace from active use and archives everything it owns. UI, CLI, and MCP always say "Archive workspace", regardless of backing. The daemon leaves ordinary directories intact and removes a Paseo-owned worktree only when no active workspace still references it.
- **Workspace kind** — `"directory" | "local_checkout" | "worktree"`. The git-derived, persisted property of a workspace, used across its lifetime (archive safety, sidebar, grouping). Derived from the cwd's git reality by `deriveWorkspaceKind` in `workspace-registry-model.ts`, not stored from a user choice. Don't confuse with **Isolation** (the create-time intent).
- **Isolation** — Create-time choice for a new workspace: reuse the existing checkout (**Local**) or cut a dedicated git worktree (**New worktree**). A transient setup input, also remembered as a create-form preference; it is not a workspace property. UI: "Isolation" control on the New Workspace screen. Code: `isolation` (`"local" | "worktree"`), `useWorkspaceIsolation` (`packages/app/src/screens/new-workspace-screen.tsx`); persisted as `FormPreferences.isolation` (`packages/app/src/create-agent-preferences/preferences.ts`). Distinct from **Workspace kind**, which is the git-derived property the intent produces (Local → `local_checkout` or `directory` by git-ness; New worktree → `worktree`). On the wire it is the create request's `source.kind` (`directory | worktree`, `packages/protocol/src/messages.ts:1693`).
- **Agent** — See **Agent session**. UI still says "Agent" / "New Agent" in places, but moving toward **Agent session** as the canonical term. Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`). Forbidden: "Task", "Job", "Run".
- **Daemon** — Local Paseo server process; identified by `serverId`. UI: "Daemon" (system contexts only). Code: `serverId` in `ServerInfoStatusPayloadSchema` (`packages/protocol/src/messages.ts:1936`), `DaemonClient` (`packages/client/src/daemon-client.ts`).
- **Host** — Client-side connection profile pointing at a daemon; bundles one or more `HostConnection`s. UI: "Host" / "Add host" / "Switch host". Code: `HostProfile` (`packages/app/src/types/host-connection.ts:37`). Forbidden: "Connection" (means `HostConnection`, not host).
- **Host badge** — The item on a sidebar workspace row's meta row naming which host that workspace lives on. Three things gate it, and they compose as a lattice rather than competing: the sidebar must span more than one host (automatic), **Show → Host** in the sidebar's display preferences must be on (global, all hosts at once), and then that host's own display mode decides _how_ it draws — **Name** / **Icon only** / **Hidden**. Name, color, and display mode are per-host, per-device, and live client-side on `HostProfile.appearance`; nothing about it reaches the daemon. Never add a fourth way to hide it. UI: the "Appearance" group on a host's settings page, and the "Show" submenu in the sidebar's display preferences. Code: `selectHostBadges` (`packages/app/src/hosts/appearance.ts`) takes the first two as its `enabled` input; `WorkspaceMetaRow` (`packages/app/src/components/sidebar/workspace-meta-row/index.tsx`) draws it. Don't confuse with **Status pill**, which reports state rather than identity.
- **Project host entry** — One row in a project for a single (project, daemon) pair, aggregating that daemon's workspaces in the project. Internal. Code: `ProjectHostEntry` (`packages/app/src/utils/projects.ts:11`). Don't introduce "Checkout" as a synonym.
- **Placement** — One workspace's relationship to its project (projectKey, projectName, git checkout snapshot). Internal. Code:`ProjectPlacementPayload` (`packages/protocol/src/messages.ts:2113`).
- **Placement** — One workspace's stable foreign-key relationship to its project plus its git checkout snapshot. Internal. An explicit creation`projectId` is authoritative when active.
- **Worktree** — Paseo-managed git worktree (`~/.paseo/worktrees/{name}`); also a `workspaceKind` value. UI: CLI + `paseo.json` keys (`worktree.setup`, `worktree.teardown`) only. Code: `ProjectCheckoutLiteGitPaseoPayload` (`packages/protocol/src/messages.ts:2092`); CLI `paseo worktree` (`packages/cli/src/commands/worktree/index.ts:8`). Forbidden: "Checkout" as a synonym.
- **Repository / Remote** — Internal git inputs (`remoteUrl`, `mainRepoRoot`) used to derive `projectKey`. No UI label.
- **Forge** — Git hosting service behind Paseo's change-request features: GitHub, GitLab, Gitea, Forgejo, or a future registered adapter. Code: `ForgeService`, `forge-registry`, `forge-resolver`. Use `forge` for internal abstraction and registry IDs; use concrete forge names only when a behavior or RPC is forge-specific.
- **Change request** — Forge-neutral term for a proposed branch-to-branch code change. UI normally renders the forge noun instead: GitHub/Gitea/Forgejo "PR", GitLab "MR". Code: `forge_change_request` attachments, `checkoutSource: { kind: "change_request" }`, and PR/MR status payloads.
- **MR** — GitLab merge request. UI label for GitLab change requests only; do not use MR for GitHub/Gitea/Forgejo.
- **Worktree** — Paseo-managed git worktree (`~/.paseo/worktrees/{name}`); also a `workspaceKind` value. User-facing creation treats it as the `worktree` workspace isolation choice. Code and `paseo.json` retain worktree terminology for git lifecycle implementation. Forbidden: "Checkout" as a product synonym.
- **Repository / Remote** — Internal Git observations. They may produce mutable kind, branch, and project-grouping metadata but never the host-local project ID, root, display name, or workspace membership. No UI label.
- **Sidebar items** — The configurable entries of the app sidebar, in two sections. Header: the rows at the head of the sidebar — New workspace, History, Search, Schedules, and plugin header items. Footer: the full-width rows between Add project and the footer icon row — Usage and plugin footer items. Add project and the icon row (Hosts, Help and support, Settings) are fixed and are not sidebar items. A plugin item may render several rows, such as one per bot; it is still one item that moves and hides as a block. Each section's order and visibility is one per-device preference (`AppSettings.sidebarNavItems`, `AppSettings.sidebarFooterItems`); every item may be off. UI: the Header and Footer cards on the Sidebar settings page. Code: `packages/app/src/sidebar-nav/model.ts` resolves a section, and the sidebar (`components/sidebar/sidebar-nav-rows.tsx`, the footer in `components/left-sidebar.tsx`) and the settings page (`screens/settings/sidebar/sidebar-nav-section.tsx`) read it through `useSidebarNavItems(section)`. Never hardcode one of these entries or add a second place that decides which ones show. Don't confuse with **Explorer sidebar**.
- **Explorer sidebar** — The dedicated navigation dock containing the Files and Changes trees. UI: "Explorer sidebar". Code: `packages/app/src/workspace-tabs/explorer-sidebar.ts`; persisted pane identifiers retain the `explorer` spelling. See [explorer-sidebar.md](explorer-sidebar.md).
- **Workspace sheet** — The compact sheet that holds one file or subagent tab over the workspace without adding it to the tab switcher. UI action: "Open as tab". Code: `sheetByWorkspace` in `packages/app/src/stores/workspace-layout-store.ts`, `WorkspaceSheetHost` (`packages/app/src/screens/workspace/workspace-sheet-host.tsx`). See [explorer-sidebar.md](explorer-sidebar.md#workspace-sheet).
- **Side pane** — An ordinary right-side workspace pane used for content opened beside the user's work. It closes and participates in workspace splits like every other main-host pane. See [explorer-sidebar.md](explorer-sidebar.md#side-pane). Forbidden: "Side panel".
- **Directory-backed surface** — A right-sidebar surface whose content is determined by the workspace's `cwd`, so two workspaces on the same directory see identical content: git diff/status, forge change-request info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
- **Workspace-owned state** — Per-workspace state that never leaks to a same-`cwd` sibling: tabs, agents, terminals, panes, title, plus review drafts, diff-mode overrides, composer attachments, and file-explorer open/expand state. Keyed by `workspaceId` (`cwd` only as a fallback for old payloads). See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
- **Workspace status bucket** — Aggregate activity signal for a workspace row. Same-`cwd` workspaces intentionally share agent and terminal status buckets, while tab, agent, and terminal visibility remains scoped by `workspaceId`.
- **Agent session** — One running instance of an agent inside a workspace (one provider, one model, one cwd, one timeline). The conceptual unit; in the UI this opens as a tab. Moving toward this as the canonical term over "Agent". Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`).
- **Session** — Two senses: (a) per-client connection to a daemon, internal; (b) user-facing agent session, see **Agent session**. Code: `Session` (`packages/server/src/server/session.ts`) for (a). Don't confuse with: provider-side agent session log.
- **Profile** — Internal name for the persisted shape of a host. Code: `HostProfile` (`packages/app/src/types/host-connection.ts:37`). Never user-facing.
- **Profile** — Internal name for the persisted shape of a host. Code: `HostProfile` (`packages/app/src/types/host-connection.ts:37`). Never user-facing. Three other things are called "profile" in this codebase, so always qualify: **Agent profile**, **Terminal profile**, and the provider aliases in [custom-providers.md](custom-providers.md). Bare "profile" is forbidden in UI copy.
- **Agent profile** — A named launch bundle: provider, model, mode, thinking option, feature values, and free-text notes. Applying one writes those values into the **Agent controls** and is then forgotten — there is no selected profile, no default profile, and no drift tracking. The notes are written for orchestrating agents and are what the `list_profiles` MCP tool surfaces. Stored host-wide in daemon config as `agentProfiles`. UI: "Agent profiles". Code: `AgentProfile` (`packages/protocol/src/messages.ts:161`). Carries no system prompt, because a per-agent system prompt can only be set at creation. Forbidden: "Favourite", which this replaced.
- **Terminal profile** — A named shell command a terminal can launch. Stored host-wide in daemon config as `terminalProfiles`. UI: "Terminal profiles". Code: `TerminalProfile` (`packages/protocol/src/messages.ts:135`), `resolveTerminalProfiles` (`packages/protocol/src/terminal-profiles.ts`). Also selectable as a New Workspace launch target, where the alternative is the chat agent.
- **Usage source** — Plugin registration that discovers accounts and fetches plan usage. Code: `UsageSourceRegistration` (`packages/plugin/src/server/usage.ts`).
- **Report ID** — Stable daemon-composed identity for one source account: `<sourceId>:<accountKey>`. The source owns the account key; clients treat the whole ID as opaque. Code: `UsageReportEntry.id` (`packages/protocol/src/messages.ts`).
- **Usage report** — A source's normalized usage for one account, including status and optional windows, balances, and details. Its entry carries the Report ID, account label, and fetch time. Each report renders as one card in the Usage modal, in host Settings, and in an agent's context window details (a hover card on desktop web, a tooltip on native wide screens, a sheet on compact ones), which show only the account that agent runs under, without pins. The hover card and the sheet have Refresh; the native tooltip takes no presses. Code: `UsageReport` (`packages/plugin/src/server/usage.ts`), `UsageCard` (`packages/app/src/usage/card.tsx`), `AgentUsage` (`packages/app/src/usage/agent-usage.tsx`).
- **Usage (sidebar item)** — The sidebar footer item showing each pinned window's source icon and percent, hidden while no pinned window has data (the footer's Usage icon remains available); both entry points open the Usage modal over the current screen: a dialog on wide layouts, a bottom sheet on compact ones. Usage has no route of its own. Off until the user turns on **Summary in sidebar** (Usage Settings) or the Usage row in Settings > Sidebar; window rows pin only while it is on, and saved pins come back when it is turned on again. A pin is `{ sourceId, windowId }` and shows once per account of that source. Pins and the used/remaining choice are one per-device preference (`AppSettings.usage`) that every usage surface follows. It reads the active workspace's host, else the first connected host with usage sources. Code: `packages/app/src/usage/sidebar-item.tsx`, `usage/usage-modal.tsx`, `usage/pinned.ts`, `usage/preferences.ts`.
- **Model** — A specific LLM offered by a provider. UI: "Model" / "Select model". Code: `AgentModelDefinition` (`packages/protocol/src/messages.ts:187`).
- **Tab** — UI surface representing one session inside a workspace. Not a conceptual unit; use **Agent session** when talking about the model. Code: `WorkspaceTabDescriptor` (`packages/app/src/screens/workspace/workspace-tabs-types.ts`).
- **Terminal** — Workspace-scoped PTY shell streamed over the binary mux channel. UI: "Terminal". Code: `TerminalStreamFrame` (`packages/protocol/src/terminal-stream-protocol.ts`).
- **Schedule** — Cron-style trigger that creates new agents. UI: CLI/MCP (`paseo schedule`, `create_schedule`). Don't confuse with: Heartbeat (cron prompt back into the same agent) or Loop (iterative re-execution of one agent).
- **Heartbeat** — Cron-style prompt sent back into the same agent/conversation. MCP: `create_heartbeat`. Use for reminders and babysitting where the status should return inline.
- **Workspace script** — A named command a workspace can run, declared in `paseo.json`. Two types: `service` and `script` (a command that runs and finishes). UI: "Scripts" for the panel and the menu, both of which list either type. Code: `WorkspaceScriptPayload` (`packages/protocol/src/messages.ts:3126`). Don't confuse with **Service**, which is one of the two types rather than a synonym for the pair.
- **Service** — A workspace script of type `service`: long-running, may bind a port, and carries `health` of `healthy | unhealthy | null` once the daemon has observed it. The only type a sidebar workspace row reports, because a running plain script says nothing about whether the workspace is up; the row names the service and turns danger when its health fails, and where several run the failing one takes the row's single slot. UI: "Services" in the sidebar's display preferences (**Show → Services**). Code: `selectWorkspaceServiceSummary` (`packages/app/src/components/sidebar/workspace-meta-row/service-summary.ts`), `WorkspaceScriptHealthSchema` (`packages/protocol/src/messages.ts:3124`). Forbidden: "Service" as a label for the Scripts panel, which lists both types.
- **Schedule** — Cron-style trigger that creates new agents. UI: CLI/MCP (`paseo schedule`, `create_schedule`). Don't confuse with: Heartbeat (cron prompt back into the same agent).
- **Heartbeat** — Ephemeral cron prompt sent back into the same agent/conversation. Agent surfaces expose create, update cron, and delete only. Use for reminders and babysitting where status should return inline.
- **Attachment** — GitHub PR or Issue bound to an agent prompt. UI: "Attach issue or PR". Code: `AgentAttachment` (`packages/protocol/src/messages.ts:782`).
- **Attachment** — External or local context bound to an agent prompt: forge issue/change request, review context, uploaded file, text, or image. UI: "Attach issue or PR/MR". Code: `AgentAttachment` (`packages/protocol/src/messages.ts:782`).
- **Composer** — The whole prompt surface for sending work to an agent. Code: `Composer` (`packages/app/src/composer/index.tsx`). Don't call this "message input" except for the text-entry subcomponent.
- **Composer input** — The text-entry surface inside the composer. Code: `MessageInput` (`packages/app/src/composer/input/input.tsx`).
- **Composer toolbar** — The bottom control row inside the composer input. Contains agent controls, attachment button, voice controls, and stop/send controls. Code: `leftContent`, `beforeVoiceContent`, and `rightContent` slots in `MessageInput` (`packages/app/src/composer/input/input.tsx`). Forbidden: "Status bar".
- **Agent controls** — Provider, model, mode, thinking, and provider-feature controls for an agent or draft agent. Code: `AgentControls` / `DraftAgentControls` (`packages/app/src/composer/agent-controls/index.tsx`). Forbidden: "Agent status bar".
- **Composer footer** — Optional area rendered below the composer input but still inside the keyboard-shifted composer layout. Code: `Composer.footer` (`packages/app/src/composer/index.tsx`).
- **Composer track** — A contextual lane above the composer input. Specific tracks use the `<thing> track` form: **Queue track**, **Subagents track**. Code: queue track inside `Composer` (`packages/app/src/composer/index.tsx`), `SubagentsTrack` (`packages/app/src/subagents/track.tsx`).
- **Composer track** — A contextual lane at the foot of a pane, by the composer input. Specific tracks use the `<thing> track` form: **Queue track**, **Subagents track**, **Tasks track**. The subagents and tasks tracks are pills floating over the transcript, each opening a panel with its rows; the queue track is a lane inside the composer itself. Code: queue track inside `Composer` (`packages/app/src/composer/index.tsx`), pill and panel frame in `packages/app/src/composer/tracks.tsx`, `SubagentsTrack` (`packages/app/src/subagents/track.tsx`), `AgentTaskList` (`packages/app/src/composer/task-list/index.tsx`).
- **Steer** — A follow-up admitted into an agent's already-running turn instead of interrupting it. Interrupting the turn discards steers the provider has not read yet; there is no take-back short of that, and no provider offers one. The stop button and Escape both interrupt. A steer the provider never read keeps its transcript row, matching Codex. Sending while the agent waits on a permission denies that request first, so the message reaches the same turn instead of stalling behind it. UI: "Send and steer". Code: `steerActiveTurn` (`packages/server/src/server/agent/agent-sdk-types.ts`). Don't confuse with **Queue track**, which holds messages client-side until the turn ends and never enters the running turn. Forbidden: "Delivery", "Queued prompt".
- **Subagent** — User-facing term for an agent session related to a parent agent session. Use **subagents** in UI copy and docs. Internal daemon/provider plumbing may say "child agent" or `child_session`, especially for provider-managed imports; do not surface "child agent" as a product term.
- **Attachment tray** — The selected-attachments row inside the composer input, above the text input. Code: `renderAttachmentTray` (`packages/app/src/composer/index.tsx`). Forbidden: "Attachment bar".
- **Fork** — Starting a NEW agent session seeded with a copy of another session's curated chat history. The history rides along as a `chat_history` composer attachment on a draft; the fork does **not** resume or branch the provider-side session, and the source session keeps running untouched. Two targets, one surface, one RPC (`agent.fork_context`, gated on the `agentForkContext` host feature). UI: the fork menu lives in the assistant turn footer and offers "Fork in a new tab" / "Fork in a new workspace". On a **completed** turn it sits beside the copy button and pins a boundary to that turn, so the fork stops where the turn ended. On the **in-flight** turn it sits beside the progress loader and elapsed timer, and omits the boundary entirely — `selectForkContextRows` projects the whole timeline when no boundary is given, so the fork captures everything **up to now**, including the response still streaming. That omission is what makes mid-run forking work; pinning a boundary there would silently drop the live response. Forbidden on provider-subagent (read-only) panes. Code: `useForkAgent` (`packages/app/src/hooks/use-fork-agent.ts`), `selectForkContextRows` (`packages/server/src/server/agent/activity-curator.ts`). Don't confuse with: git fork (unrelated; Paseo has no such concept), **Worktree** (filesystem isolation, not history copying).
- **Conflict** — Two distinct senses; do NOT use the bare word in UI copy without qualifying which: (a) **stale-write conflict** on `paseo.json` ("Config changed on disk", code `stale_project_config`, `packages/app/src/screens/project-settings-screen.tsx:593`); (b) **git merge conflict** (no current UI string).
- **Product discussion** — Where non-bug product talk goes: [GitHub Discussions](https://github.com/getpaseo/paseo/discussions) and `#product` in Discord. Covers workflows, features, and product direction. The container term; the ask inside it is always a **workflow** — what you're doing, how you do it today, where Paseo gets in the way. Issues are bugs only. Forbidden: "Feature request" as a Paseo surface, label, or invitation; the phrase is only used when answering someone who arrived with it. Don't invent "feature discussion", "idea", or "workflow thread" as synonyms. See [CONTRIBUTING.md](../CONTRIBUTING.md).
@@ -96,6 +96,21 @@ Any gap between A and B (margins between siblings inside the same parent) is par
If A and B genuinely can't share a parent — B portals into a different layer, floats above other content — see [Section: real gaps](#real-gaps-with-floating-panels) below.
### Failure mode 4 — A hover-revealed trigger unmounts its own open menu
A kebab that only exists while its row is hovered opens a menu, and that menu takes the pointer
off the row: a sheet slides up over it, a popover covers it. The row un-hovers, the trailing
overlay unmounts, and the trigger goes with it — taking the menu's open state, which lived
inside the trigger's subtree. The surface itself is already presented in a portal, so nothing
unmounts it and nothing is left that can close it. On a bottom sheet that means a full-screen
backdrop swallowing every click until reload.
Lift the menu's open state to whatever decides to render the trigger, and keep the trigger
rendered while the menu is up. `useOpenKebabMenuVisibility`
(`packages/app/src/components/sidebar/use-open-kebab-menu-visibility.ts`) is that shape for the
sidebar rows: it owns `open`, hands the menu its controlled props, and ORs `open` into the
row's own `showKebab`.
## Native fallback
Hover doesn't exist on touch devices. Anything you hide behind hover must have a non-hover path on native and compact layouts:
@@ -120,7 +135,7 @@ Heuristic: if your hover state is going to be `useState`'d and read by anything
Sometimes the revealed content can't live inside the trigger — a hover card portals into a different layer, a tooltip floats above other content, a popover renders into a `Portal`. There's a real visual gap the user has to cross with the cursor.
For this case, use`useHoverSafeZone` (`packages/app/src/hooks/use-hover-safe-zone.ts`). It computes a rectangular "bridge" between the trigger and the content; while the pointer is inside trigger, content, or the bridge, the card stays open. A short grace timer absorbs jitter at the edges. The canonical caller is `packages/app/src/components/workspace-hover-card.tsx`.
For a card whose content the user can point at or press, use `HoverCard` (`packages/app/src/components/ui/hover-card.tsx`). It is built on`useHoverSafeZone` (`packages/app/src/hooks/use-hover-safe-zone.ts`), which computes a rectangular "bridge" across the gap between the trigger and the content, whether the card sits beside, above, or below the trigger. While the pointer is inside trigger, content, or the bridge, the card stays open. A short grace timer absorbs jitter at the edges. `Tooltip` takes no pointer events and closes as soon as the pointer leaves the trigger. If the trigger has no focusable child, give `HoverCardTrigger``focusable` and an accessible label so keyboard users can open the same content.
Don't roll your own. The math is annoying, the edge cases (pointer leaves window, drag in progress, content unmounts) are subtle, and we already paid for the hook.
@@ -134,5 +149,6 @@ Before opening a PR that touches hover:
- [ ] Hovered state does **not** change the trigger's outer geometry (`width`, `height`, `padding`, `borderWidth`, mount/unmount of siblings that shift it). Internal swaps fit inside a fixed `minHeight` / `minWidth`.
- [ ] Revealed content inside the trigger uses `opacity` + `pointerEvents`, not conditional rendering, if mounting it would reflow the trigger.
- [ ] Visibility on native and compact layouts works without hover (`isHovered || isNative || isCompact`).
- [ ] If the revealed content sits in a separate layer (portal, floating panel), `useHoverSafeZone` is wired up.
- [ ] A menu opened from the revealed trigger keeps the trigger rendered while it is open, so losing hover can't strand it.
- [ ] If the revealed content sits in a separate layer (portal, floating panel) and the user can point at it, it is a `HoverCard`.
- [ ] You opened the dev server, hovered the trigger, and slowly moved the mouse along **every** revealed element — including any visible gaps — without losing hover state.
Paseo Hub is an explicit opt-in connection from one Paseo daemon to one Hub. Running a daemon does
not register it with a Hub. The relationship begins only when a user runs
`paseo hub connect [url]` from the daemon machine with an explicit API key or matching stored CLI login.
The human CLI login and daemon relationship are separate identities. `paseo hub login [url]` stores a durable organization-scoped CLI credential keyed by normalized Hub origin under `PASEO_HOME`. Interactive login optionally connects the local daemon, then points to the Hub UI for trigger configuration; it does not scaffold or deploy configuration. `paseo hub init` remains the explicit triggers-as-code scaffold: it writes one self-contained organization trigger under `.paseo/triggers/`, validates it through the trigger API, and optionally installs it. `paseo hub deploy` validates and installs every trigger in that directory; passing `--project` keeps deploying the legacy project bundle instead. `paseo hub export [directory]` writes the active organization's current triggers in the same layout, using the active login unless another Hub or API key is selected. Origin resolution uses explicit command input, `PASEO_HUB_URL`, active login, then `https://hub.paseo.sh`. Connect uses exact-origin authority to request a one-time enrollment token, then passes only that token to the daemon. The daemon generates and persists its own relationship credential.
## Connection and authority
The daemon enrolls over HTTP(S), then opens and maintains a direct outbound WebSocket to the Hub.
The Hub never discovers or acquires the daemon through Paseo's relay. The relay remains an optional
encrypted path for normal Paseo clients and has no role in Hub enrollment, authentication, dispatch,
or reconnects.
The daemon persists a relationship ID and private connection credential before enrollment. The
relationship is independent of its current transport, so a future transport can replace the direct
WebSocket without pairing again. The current foundation supports one Hub relationship per daemon.
Normal authenticated daemon sessions may manage the daemon's Hub relationship and permissions.
Hub connections have no daemon permissions by default. Connecting gives Hub machine identity and
presence but no execution authority. The `hub.execute` permission lets workflows triggered from
GitHub, Slack, Discord, Linear, and other integrations create workspaces and run agents. Grant it
during interactive login or later with `paseo hub permissions grant hub.execute`. Relationships
created before this split migrate their legacy execution scope to `hub.execute`. Hub sessions cannot
manage their own relationship or permissions.
## Session grants and agent operations
Hub uses the same authenticated, resumable Session protocol as other clients. Its persisted
cancellation, archival, agent/workspace observation, timeline subscriptions, and workspace recovery. This authority is
daemon-wide; it is not limited to agents created by that Hub. Daemon configuration, terminals,
browser control, and permission management still require their own permissions. See
[permissions.md](permissions.md).
Creating an agent may create a directory workspace. The same `hub.execute` session can title that
workspace through `workspace.title.set.request` without `workspace.manage`.
Clients using this contract check `server_info.features.hubAgentRpc` and
`server_info.features.agentRequestReceipts` once. An older host must be upgraded; do not silently
fall back to creating a fresh agent when continuation was requested.
Hub owns conversation keys, trigger policy, execution records, and the mapping to workspace/agent
IDs. None of these routing concepts are part of the generic daemon RPCs. Creation accepts ordinary
provider configuration, exact MCP tool policy, environment, and workspace/worktree selection.
Private session configuration is persisted for recovery and is not exposed in agent snapshots.
Provider controls remain provider-native; see [providers.md](providers.md).
`create_agent_request.idempotencyKey` identifies one creation operation. With a key, omit
`initialPrompt`, persist the returned agent/workspace identity, then deliver the prompt using
`send_agent_message_request` with a stable `messageId`. Request IDs correlate individual attempts;
creation keys and message IDs identify the operation across attempts. A creation key is daemon-wide;
a message ID is scoped to its agent. Reusing either with different arguments is a conflict.
`workspace.create.request.idempotencyKey` provides the same creation guarantee for directory and
worktree workspaces; check `server_info.features.workspaceRequestReceipts` before using it.
Workspace creation keys and agent creation keys have separate namespaces. The app keeps these keys
for the lifetime of a draft and reuses the first message ID after failures.
The daemon journals the assigned agent ID before creation. Concurrent retries share one operation,
and a retry after a lost acknowledgement or restart returns the durable agent without creating a
workspace or starting a turn. Deleting that agent does not make its old creation key reusable.
Confirmed message delivery is also journaled, so retrying its message ID does not submit it again.
An unfinished delivery after restart reports `agent_request_outcome_unknown`: a provider can have
accepted a prompt before the daemon recorded success, so automatic resubmission could duplicate
work. Inspect the agent before choosing a new message ID. Receipts contain identity and request
hashes, never prompts, environment values, or credentials.
Before messaging an archived workspace, call `workspace.recovery.inspect.request`, then
`workspace.recovery.restore.request` and await success. The native message handler unarchives the
agent and loads its persisted provider session. `activeTurnBehavior: "steer"` uses the provider's
native steering behavior, including Paseo's existing behavior when that provider cannot steer.
Execution completion and arrival-specific output authority remain Hub responsibilities.
The older `hub.execution.*` RPCs remain accepted for existing clients. Their execution ownership
and create deduplication behavior are unchanged; new continuation work uses the ordinary RPCs.
## Disconnect and revocation
Normal socket loss reconnects the active relationship with bounded exponential backoff and jitter.
Daemon restart loads the same relationship and credential and reconnects without another enrollment
ceremony.
Hub authentication rejection or close code `4403` permanently revokes the local relationship. The
daemon deletes its credential, stops reconnecting, and retains only the relationship ID, Hub origin,
scopes, and a sanitized reason for status reporting.
`paseo hub disconnect` disables socket reconnect and execution authority before making one bounded
remote revocation request. The daemon then removes the local relationship whether the request
succeeds or fails. A failed request returns a warning that server-side revocation may remain pending.
`--force` skips the remote request. Legacy persisted `disconnecting` records are removed on startup;
the daemon does not retry revocation in the background.
`paseo hub logout` removes only the active human CLI credential and preserves credentials for other origins. Interactive logout inspects and optionally disconnects a same-origin daemon before deleting the login; a failed requested disconnect preserves the login. JSON and noninteractive logout never prompt or disconnect implicitly.
## Cross-repository compatibility
The consumer implementation lives in Paseo Cloud. Cloud owns its copy of the Hub wire schemas and
has no Paseo runtime or build dependency. Cross-repository end-to-end verification separately builds
a Paseo source checkout and exercises the real daemon, CLI, direct WebSocket, Cloud service, and
Postgres. That compatibility fixture is not a package dependency or fallback implementation.
Its `hub-e2e` ACP provider accepts only exact tool names on the injected `hub` MCP server. Other
custom ACP providers remain unsupported for unattended preapproval.
@@ -8,10 +8,13 @@ Paseo client UI translations live in `packages/app/src/i18n`.
- `ar`
- `es`
- `fr`
- `ja`
- `ko`
- `pt-BR`
- `ru`
- `zh-CN`
The persisted app language setting is `"system" | "ar" | "en" | "es" | "fr" | "ru" | "zh-CN"`. `"system"` follows the device or browser locale when it maps to a supported locale; unsupported system locales fall back to English.
The persisted app language setting is `"system" | "ar" | "en" | "es" | "fr" | "ja" | "ko" | "pt-BR" | "ru" | "zh-CN"`. `"system"` follows the device or browser locale when it maps to a supported locale; unsupported system locales fall back to English. Japanese maps from system locales `ja` and Japanese regional locales. Korean maps from system locales `ko` and Korean regional locales. Brazilian Portuguese maps from system locales `pt-BR` and bare `pt`; other Portuguese regional locales remain unsupported until explicitly added.
## Translation Scope
@@ -21,7 +24,7 @@ Do not translate agent output, daemon output, terminal contents, file paths, pro
## Adding Copy
English source strings live in `packages/app/src/i18n/resources/en.ts`. Simplified Chinese strings live in `packages/app/src/i18n/resources/zh-CN.ts`.
English source strings live in `packages/app/src/i18n/resources/en.ts`. Each supported locale has a matching resource in `packages/app/src/i18n/resources`.
For migrated screens and components, use `useTranslation()` and pass translated text into UI primitives. Low-level primitives such as `<Button>` do not import translation state unless they own the text they render.
@@ -33,7 +36,16 @@ Run:
npx vitest run packages/app/src/i18n/resources.test.ts --bail=1
```
The parity test catches missing keys between English and Simplified Chinese resources.
The parity test catches missing keys across English and every supported locale resource.
## Forge-Variant Copy
Strings that vary by git forge follow a two-tier rule:
- **Indeclinable tokens** — brand names ("GitHub", "GitLab"), the PR/MR initialism, number prefixes (`#`/`!`) — are interpolated into a single key (`"Refresh git and {{brand}} state"`). These tokens stay latin and uninflected in every supported locale, so one string per locale suffices. The value comes from the forge manifest via `getForgePresentation`.
- **Sentences containing the full change-request noun** ("pull request" / "merge request" inflects and takes gender/case in translation) use the i18next `context` mechanism: the base key carries the pull-request wording and an `_mr` sibling carries the merge-request wording (`pullRequest` / `pullRequest_mr`). Call sites pass `t(key, { context: getForgePresentation(forge).changeRequestContext })`; an undefined or unknown context falls back to the base key.
Keys scale per vocabulary family (PR vs MR), not per forge: a new forge picks an existing family in its manifest entry and needs zero locale edits.
## Migration Order
@@ -77,3 +89,5 @@ Within a migrated surface, do not leave mixed-language neighboring labels when t
- Batch 4Y tightened the translation boundary so React components and custom hooks use `useTranslation()` while pure helpers keep direct `i18n.t(...)` fallbacks, and migrated remaining small UI/accessibility fallbacks across message details, menu backdrops, startup errors, sidebar PR badges, settings/project accessibility labels, composer send/create/download fallbacks, client slash-command descriptions, terminal subscribe errors, and desktop update completion text. Provider catalog metadata, shortcut registry fallbacks, agent/daemon/protocol reasons, terminal contents, raw runtime errors, and user/project/workspace names remain untranslated.
- Batch 4Z expanded the supported locale set to the six UN official languages: Arabic, Chinese, English, French, Russian, and Spanish. Arabic, French, Russian, and Spanish now have full client-owned UI resource coverage, with key parity, fallback-ratio, and interpolation-placeholder tests guarding the generated translations. Arabic does not enable RTL layout direction in this batch.
- Batch 5A added Brazilian Portuguese (`pt-BR`) resource coverage, language selector labels, i18next registration, and system-locale mapping for `pt-BR` and bare `pt`. Non-Brazilian Portuguese regional locales remain unsupported until a matching resource is added.
- Batch 5B added Korean (`ko`) resource coverage, language selector labels, i18next registration, and system-locale mapping for `ko` and Korean regional locales.
Agent Device `.ad` scripts are the primary mobile E2E format. An agent discovers a working flow interactively, saves the successful commands, then the replay runner executes the same typed plan locally or in CI.
# Continue the flow and verify its result with wait/get/is/find.
agent-device close --session terminal-author
```
`close` writes the script. Keep selectors based on stable app IDs. Keep assertions as `wait`, `get`, `is`, or `find` commands; screenshots are evidence, not assertions.
Run the Paseo mobile suite:
```bash
npm run test:e2e:mobile
```
The runner uses an isolated Agent Device state directory, verifies or starts Metro for this checkout, prewarms the iOS runner, discovers each script's platform from its `context` header, and cleans its sessions, runner lease, daemon, and any Metro process it started. Attempt results, timings, logs, and failure artifacts go under `.dev/agent-device-artifacts`.
Set `PASEO_MOBILE_E2E_METRO_PORT` when this worktree already has Metro on a non-default port:
```bash
PASEO_MOBILE_E2E_METRO_PORT=62093 npm run test:e2e:mobile
```
[native-terminal-basic.ios.ad](../packages/app/e2e/mobile/agent-device/native-terminal-basic.ios.ad) and [native-terminal-basic.android.ad](../packages/app/e2e/mobile/agent-device/native-terminal-basic.android.ad) are the smallest examples. Each opens a fresh terminal, types a command at zero delay, submits it, and asserts its distinct output. The app must be connected to a daemon with an active workspace.
For Android keyboard continuity, run the current checkout in the app, open an idle terminal
When replay diverges, read its ranked selector suggestions. Edit the script deliberately and rerun it from the beginning. `--update` is retained for compatibility but no longer rewrites scripts.
## Maestro compatibility
Existing Maestro flows live in `packages/app/maestro/`. Agent Device can execute its supported subset with `agent-device test <path> --maestro`, which provides a migration path while `.ad` coverage replaces these flows.
### Existing flows
Maestro flows live in `packages/app/maestro/`. Reusable sub-flows live in `packages/app/maestro/flows/`.
@@ -198,6 +298,40 @@ for i in $(seq 1 "$ITERATIONS"); do
done
```
### Android audio focus interruptions
Voice mode uses the custom `expo-two-way-audio` Android module, so incoming calls and other system audio owners must be tested with emulator/system commands, not a JS-only test. To verify that voice resume handles denied audio focus without crashing:
```bash
adb shell am start -n sh.paseo/.MainActivity
# Start voice mode in an existing composer, then background Paseo with Home.
adb emu gsm call 5551234
# Foreground Paseo while the call is still ringing.
```
Expected result: Paseo does not throw `RuntimeException: Audio focus request failed`; native audio reports an interruption and voice mode stops or pauses coherently.
### Releasing the audio session when idle
Paseo must not hold the OS audio session once it is neither capturing nor playing, or the user's
background music stays paused. On iOS this is not just a "while recording" problem: the
`.playAndRecord`/`.voiceChat` category is non-mixing and survives backgrounding, and iOS re-asserts
it every time the app returns to the foreground — so one dictation turn kills music for the life of
the process, on every open, until the app is force-quit. Android holds
`AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE` with the same effect. It must also reset
`MODE_IN_COMMUNICATION` and clear the selected communication device; abandoning focus alone can
leave Bluetooth earbuds on their narrow-band call route without an active microphone indicator.
`releaseAudioSession()` whenever capture stops and the playback queue drains, and the iOS module
also releases on `OnAppEntersBackground`. The native side re-guards on `isRecording` /
`speechPlayer.isPlaying`, because the native engine is a singleton shared by multiple JS engine
wrappers (voice provider + dictation) and only it knows the true state.
This cannot be validated by a JS test — verify on a device: play music through Bluetooth earbuds,
open Paseo, use dictation once, stop, and confirm the music resumes at full quality; then
background/foreground the app and confirm it keeps playing at full quality.
## Unistyles + Reanimated
### The crash
@@ -266,6 +400,111 @@ Platform-specific stream edges belong on `StreamStrategy`:
If a chat footer looks duplicated or appears above the assistant message on mobile, start with `packages/app/src/agent-stream/layout.test.ts`. Do not add a React Native renderer test for this class of bug; make the pure layout invariant fail first.
## Local iOS device builds
Native changes (anything under `packages/expo-two-way-audio/ios/`, or any new Expo module) **cannot be
tested over Metro or EAS Update**. Those ship JS only. A stale dev-client binary silently lacks the new
native functions, so the feature no-ops and the test proves nothing. Rebuild the binary.
Symptom of a stale dev client: `Cannot find native module 'ExpoDocumentPicker'` at startup, followed by a
cascade of `Route "./X.tsx" is missing the required default export` warnings. The route warnings are not a
router bug — the module-level throw aborts `_layout.tsx` evaluation, so its default export never gets
assigned. One missing native module, N confusing warnings.
**Prerequisite: Xcode needs a signed-in Apple ID for the signing team _with a valid keychain token_.** An
account that merely appears in Xcode → Settings → Accounts is not enough — if its `Xcode-Token` is missing,
the build below fails at code signing and blames a _certificate_ instead. See
[Signing needs a working Apple ID token in Xcode](#signing-needs-a-working-apple-id-token-in-xcode) before
The daemon authorizes principals with semantic permissions. RPC names and protocol namespaces are not authority.
## Model
```text
principal -> grants
|
+-- authenticated by a device or service credential
`-- opens a session with equal or narrower authority
```
A principal is the durable identity the daemon authorizes. A credential proves that a device or service represents it. Keep them separate so you can rotate credentials, attach more than one device, and revoke a Hub user without inventing daemon user accounts.
A pairing invitation is neither. It is an expiring, single-use exchange that creates a principal and credential with the permissions selected by its issuer.
Agents and terminals use workspace authority. Both can execute code and mutate the workspace, so separate write permissions would claim an isolation boundary the daemon cannot enforce.
Owner, operator, and viewer are UI presets expanded into explicit permissions. Do not persist them as roles. Adding a permission must not silently widen an existing principal.
Permissions are additive allows. Missing authority denies the operation. Do not add deny precedence.
## Resources
Permissions are daemon-wide today. Future grants may select workspaces or agents, but operation classification remains inside the authorization module:
A delegating principal can grant only authority it already possesses. A session may attenuate its principal's grants but cannot widen them.
Workspace-scoped grants require every resource-bearing operation and outbound observation to enforce the same workspace boundary. File preview currently accepts any daemon-readable regular file, so it must gain resource enforcement before workspace-specific access ships.
## Hub
The Hub authenticates as a service principal. Its locally selected grants decide whether it may execute agents, manage the daemon, manage tunnels, or manage access.
Hub user and role identifiers remain opaque external subjects. The Hub may create and revoke linked daemon principals when granted `access.manage`; the daemon does not interpret accounts, organizations, or roles.
Hub enrollment and permission updates exchange these semantic permissions directly. Legacy persisted Hub relationships that contain `hub.execution.*` migrate once to `hub.execute` when the daemon loads them; new relationships never persist or emit transport scopes as authority.
Paseo executes `index.server.ts` in a subprocess and `index.client.tsx` in every connected app.
> **Trust every plugin you add.**`paseo plugin add` and `paseo plugin install` mean “I trust this codebase.” Plugins are unsandboxed: server code and preparation commands run with the daemon user's access on the daemon host, and client contributions run inside Paseo. The repository's dependencies and future updates are part of that trust decision. With `--host`, preparation runs on that remote daemon host.
## Install a directory source
Create a typecheckable plugin project, install its development dependencies, then install it into
the daemon. `init` only writes the project files; it does not run the package manager.
paseo plugin install github:owner/repository --ref main
```
Append `:relative/path` for a plugin below the repository root. `--ref` chooses the initial
branch, tag, or commit once. Updates of explicit Git sources resolve the remote's default HEAD
and ask for approval. `ls` reports the installed commit without contacting the remote.
## 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. 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
because authenticated clones may have a rewritten placeholder origin. Fixed owned directory layout
provides the selected subdirectory, npm package name and cleanup root. Git HEAD and the installed
npm package.json checked against package-lock.json provide the current revision. Keep the complete
npm dependency tree and lockfile after staging activation. No install selector governs updates.
The loader sees only the configured directory and never invokes npm. npm lifecycle scripts are
disabled during acquisition; manifest preparation owns required commands before validation.
List responses retain the closed `source: directory | git` enum. The optional `installation`
projection carries identity and current revision. Old npm metadata remains accepted but is no longer
The contribution function must return cleanup. Server cleanup may be async; Paseo waits for it when
the plugin is reloaded, disabled, removed, disconnected, or shut down. Cleanup is for resources
created by plugin code. Paseo removes registered contributions, unmounts surfaces, clears query
state, rejects pending RPCs, closes the plugin's daemon session, and stops the subprocess. Cleanup
errors are logged and do not interrupt host teardown.
Paseo owns the route, screen header, Lucide icon validation, close action, theme DTO, layout facts,
and render error boundary. The contributed component owns the complete body below the header.
The plugin owns the header's text through the screen's `title` (`resolvePluginScreenTitle` in
`plugins/surface-contribution.ts`). A legacy `addSidebarItem` route (`/sidebar/<id>`) shows the
item's title and icon instead, as before screens had titles; the `addSurface` alias titles its
screen with the id. A `/sidebar/<id>` route with no legacy item of that id opens the screen with
that id, so links saved before a plugin migrated keep working.
Screen params ride in the screen route's query string (`plugins/routes.ts`), not in app state, so
reload, history, links, and the host switcher keep them without a store. Expo Router merges the
route's segments (`serverId`, `pluginId`, ...) into the same search params as the query, so param
keys are stored under a `param.` prefix; plugins never see it and may use any key.
`openScreen` validates its input once (`parsePluginOpenScreenInput`); code past it trusts the
params.
RPC contracts validate inputs and outputs in both the app and plugin subprocess. `useRpc` returns a
typed async function. Use the host-provided `@tanstack/react-query` for request state and caching;
Paseo gives each plugin installation its own query client.
`usePaseo()` and the handler's `{ paseo }` context expose the same `PaseoApi`: projects,
workspaces, agents, terminals, providers, and daemon config. They do not expose connection lifecycle.
`usePaseo()` is the plugin's one client: `InstalledPlugin.paseo`, created with the installation in
`packages/app/src/plugins/registry.ts` over the host's existing connection and passed to setup as
`client.paseo`. Authors release their subscriptions in their own cleanup, and teardown (disable,
reload, removal, host removal) disposes the client, which ends the rest; nothing is created or
disposed per mounted surface. See the
[public example](../public-docs/plugins/reference.md#use-the-paseo-sdk). Switching the screen's host
changes both `usePaseo()` and `useRpc()` to that host's installation. An offline selected host fails there and never falls through to another
installation. A server handler owns an IPC-backed daemon session for the life of its subprocess.
Use plugin RPC for plugin-specific backend behavior that is not a normal Paseo operation.
Host-targeted clients and discovery are owned by `packages/app/src/plugins/hosts`, with per-installation
bindings supplied by the bundle loader. Bind the imperative getter to that installation; do not
resolve ownership through a mutable current-plugin global. Keep observation ownership in this module
and transport ownership in the app host runtime. See the [public host API contract](../public-docs/plugins/reference.md#discover-hosts-and-target-another-host).
Each subprocess gets an exclusively owned `plugin:<id>` session. That identity is reserved from
normal clients, never resumes another session, and is cleaned immediately on exit without reconnect
grace. During daemon startup, plugin sessions may connect while application WebSockets remain
paused; the daemon accepts clients only after configured plugins have settled and the initial
catalog is complete.
When the same plugin contribution exists on multiple hosts, Paseo shows it once in the sidebar and
adds a host picker to the screen header. The selected host supplies the bundle, RPC transport, and
query cache. Explicit SDK targets follow the [host API contract](../public-docs/plugins/reference.md#discover-hosts-and-target-another-host).
Workspace panels, Command Center items, and client slash commands are client contributions. The
daemon transports their compiled bundle without interpreting placement or callbacks. Panel props
contain workspace and agent IDs. Required-selector hooks read normalized client state synchronously
and use shallow equality, so a panel does not subscribe to fields it does not render. Command
callbacks materialize their snapshots only when invoked. Contribution discovery and panel opening
never fetch active context through plugin RPC. Snapshot DTOs are deeply readonly and frozen at
runtime so plugin code cannot mutate normalized app state or a memoized selection. Panels use one persisted
`plugin` workspace-tab target, so reload, disable, removal, and restoration resolve through the
current installed-plugin catalog. A missing contribution renders unavailable inside the tab.
Panels declare `locations: ["workspace", "explorer"]` to opt into Explorer hosting; omission means
workspace only. Location controls hosting, not context. An agent panel target keeps its `agentId`
when moved between hosts. Explorer configuration can create workspace-context panels and remove
existing agent-context instances, but it cannot create an agent panel without an agent-aware command.
Command Center callbacks use the selected host's existing `PaseoApi` for normal Paseo operations.
They use typed plugin RPC only for plugin-specific backend work. Surface and panel navigation
belongs to the app; plugins do not receive Expo Router or workspace-layout store access.
See the public [navigation fields](../public-docs/plugins/reference.md#screens-and-sidebar-items)
and [external links and workspace browsers](../public-docs/plugins/reference.md#external-links-and-workspace-browsers)
for the author-facing contract.
## Lifecycle hooks
Server entries register lifecycle observers with `server.on()` and request transforms with
`server.before()`. The [public reference](../public-docs/plugins/reference.md#lifecycle-hooks)
owns callback shapes, ordering, and failure behavior. `plugin-examples/lifecycle-logger` registers all
eleven hooks; `plugin-examples/lifecycle-actions` demonstrates common automation callbacks.
Emit from the operation owner, not a client subscription. Provider history replay must not trigger
live hooks. Observers must not be awaited inside agent mutations: a callback can send a prompt or
answer a permission through its own daemon session. Awaiting it there deadlocks that command.
## Contribute a provider
Register a provider from `index.server.ts`. The provider connection is callback-based and owns all
of its sessions; plugin RPC is not part of the provider data path.
```ts
import type { PluginServerContext } from "@getpaseo/plugin/server";
import type { ProviderRegistration } from "@getpaseo/plugin/server/provider";
import { createProvider } from "./server/provider";
export default function contribute(server: PluginServerContext) {
configuration, permissions, persistence, and complete timeline snapshots through `onEvent()`.
Route messages, structured commands, steering, and command side effects through `session.prompt`.
Provider settings are toggle/select data that Paseo renders in the composer. Keep private options in
the opaque `ProviderSessionConfig.providerOptions` object on `session.open`.
It contains the provider defaults and per-agent overrides merged by the daemon.
Validate and apply it inside the provider; core does not know your option shape.
See [provider options](custom-providers.md#provider-options) for configuration and
merge semantics.
Agent refresh closes the current provider session and opens it again with current configuration and
persistence. Re-read credentials and provider-owned configuration on `session.open`; consume the
daemon launch from `connect` and the per-session env and MCP servers from `session.open`. There is
no provider reload input.
For an ACP command, register `runAcpProvider({ id, label, command })` from
`@getpaseo/plugin/server/acp`. Its transformer hooks cover narrow vendor differences; do not translate the
whole provider event stream. The shim uses the resolved launch for every probe and session, overlaying
only the supplied per-session env for sessions. The direct and ACP examples live in
`plugin-examples/provider-direct`
and `plugin-examples/provider-acp-transformer`.
Provider-emitted plugin timeline items use the same renderer registration as transformed and
daemon-appended plugin items. The direct example includes both sides. The renderer-only
`plugin-examples/inline-thinking` example shows that timeline presentation remains independent of a
provider implementation. The public [provider plugin guide](../public-docs/plugins/providers.md)
owns author workflow, lifecycle, testing, and distribution guidance.
`ProviderRegistration.icon` is a file path relative to the plugin directory, such as `icon.svg`.
It must resolve inside that directory to a regular SVG file no larger than 64 KiB. The SVG must be
self-contained: scripts, styles, `foreignObject`, event-handler attributes, JavaScript URLs, and
external `href` or `xlink:href` references are rejected. Fragment references such as `#mark` are
allowed. Paseo reads and sanitizes the file when the plugin starts; the string is never an inline
SVG or URL.
## Usage sources
Register a usage source from `index.server.ts` with `server.registerUsageSource()`. Import `UsageSourceRegistration` and normalization helpers from `@getpaseo/plugin/server/usage`. The plugin owns account discovery, harness labels, and credential-store reads; the daemon owns account grouping and the fetch cache. Agent popovers use only the agent's own login. The host-wide Usage modal falls back across an account's logins; any success shows usage only, and all failures show every login's labeled error and remedy. Follow the [usage source discovery contract](../public-docs/plugins/reference.md#usage-sources). The resolved launch environment crosses into the trusted, unsandboxed plugin subprocess for session discovery. Usage queries never run lifecycle hooks. Inputs are validated in the plugin process and remain daemon-side. `icon` uses the same sanitized SVG file rules as provider icons.
The daemon calls discovery for `usage.list_reports`; the client gates this RPC on `server_info.features.usageSources`. The old `provider.usage.list` RPC maps discovered reports for older clients.
## Contribute sidebar items
Sidebar header and footer items are plugin components, not descriptors. The
[public reference](../public-docs/plugins/reference.md#sidebar-items) owns the author API. The host
lives in `packages/app/src/plugins/sidebar-items/`; `packages/app/src/sidebar-nav/model.ts` resolves
one section's order from built-ins, plugin groups, and the section's preference
(`sidebarNavItems` or `sidebarFooterItems`).
- The item's `Component` renders directly in the section, with no wrapper, so a fragment or array
of rows lays out like separate items while Settings keeps one entry for the block. Footer items
are rows between Add project and the footer's icon row. The icon row (Hosts,
Help and support, Settings) is fixed app code, not a contribution slot, so the kit has no icon
button.
- `openPopover` opens through the same `PluginPopoverSurface` as header buttons
(`plugins/popover.tsx`). Any press inside a `SidebarRow`, including its `trailing` content, moves
the menu anchor to that row: a capture-phase `onStartShouldSetResponderCapture` runs before the
pressed child claims the touch on native and web, and returns false. Keyboard activation skips
the responder system, so the row's own press anchors too. Until a press, the first mounted row
holds the anchor. An item that renders no kit component
anchors to its section container.
- `SidebarSeparator` reuses the app's separator and cancels the footer's horizontal padding, so
the line runs edge to edge in both sections.
- `SidebarRow.trailing` sits in a second `Pressable` beside the row's button, since web cannot nest
buttons. Its `onPress` is the row's, so a press on non-interactive trailing content presses the
row; a button inside it wins its own press because only the innermost pressable responds.
- A new-API item renders from the current route's host, else the host remembered under
`pluginScreensHostKey(pluginId)`, which the screen's host switcher and the item's `openScreen`
set. One key per plugin: switching host on any of its screens moves all its items.
- `addSurface`, `openSurface`, and `addSidebarItem` are undocumented aliases, tagged
`COMPAT(pluginSidebarAliases)`. `addSidebarItem` is recorded only in `legacySidebarItems` and
groups as a `legacy` sidebar group, which the app draws as its own row: its registered icon, in
the sidebar and in Settings > Sidebar, opening `/plugin/<id>/sidebar/<item>` on the host it
remembers under its own key. It shares header ids and the `plugin:<pluginId>:<id>` settings key
with new-API items.
## Contribute buttons
Header buttons and composer pills share the client-only descriptor and registration lifecycle in
`packages/app/src/plugins/buttons/`. The [public button reference](../public-docs/plugins/reference.md#header-buttons)
owns the author API and placement rules. Keep presentation policy in this module so another
placement can reuse behavior without copying registration or action state.
Native sheets teleport their children. Button surfaces rebuild the installation's SDK, state,
query, and toast providers inside the surface content, including overflow pages from different
plugins. Providers only around the trigger do not reach those bodies.
Request observation with `client.paseo.agents.list({ subscribe: {} })` and consume the returned
`subscription` handle. Plain `list()` and agent/workspace directory `.subscribe(handler)` listeners create no daemon
demand. Provider and project `subscribe()` calls establish their own demand. On capable daemons, each
list-and-subscribe call has its own server ID, even for the same query. Older daemons retain
What Paseo is, who it's for, and where it's going.
Paseo is an environment for running, monitoring, and interacting with coding agents across desktop, mobile, web, and the command line.
## What is Paseo
**Paseo has a lean, opinionated core built to be extended.** It should be easy to start using and leave room for people to build far beyond the default experience.
Paseo is a next-generation development environment built around agents. One interface to run, monitor, and interact with coding agents across desktop, mobile, terminal, and web.
## Agents are the focus
The development workflow is shifting from manually editing files to orchestrating agents that do the editing. Paseo is built for that workflow.
The central workflow is giving an agent a task, understanding what it is doing, providing direction, and reviewing the result. Files, terminals, diffs, and other supporting tools help you do that.
## Core philosophy
Paseo should make this workflow coherent across providers and devices. You should be able to use the agent that fits a task and keep working from another device without rebuilding your environment around it.
Freedom and flexibility. Every design decision follows from this:
This is the basis for product decisions. A familiar feature from another development tool earns its place by improving that experience. Familiarity alone does not make it a requirement for Paseo.
- **Multi-provider** — Use any coding agent harness. Pick the right model for each job, switch freely as the landscape shifts. No vendor-lock in.
- **Cross-device** — Desktop, mobile, web, CLI. Start work at your desk, check progress from your phone, script from the terminal.
- **Self-hosted** — The daemon runs on your machine. Your code, your keys, your environment. No inference markup, no cloud dependency.
- **Respectful** - No telemetry, no forced cloud, no forced accounts
- **Open source** — AGPL-3.0. Users can inspect, fork, and contribute.
- **BYOK** — Bring your own keys. Use your subsidized plans and first-party provider pricing. Paseo adds zero cost on top.
## A low floor and a high ceiling
## How it works
### Easy to start
### Projects and workspaces
The default experience should be lean, polished, and understandable to someone who has never run a server or configured a VPN.
Projects are grouped in the sidebar, detected automatically from your filesystem and tagged by git remote when available.
With a supported coding agent installed and authenticated, you can open the desktop app and start working. The app manages its local daemon. To connect your phone through the optional Paseo relay, enable pairing and scan a QR code. You do not need to configure a VPN or understand the networking behind it.
Each project opens as a workspace. For git projects, the default workspace is the main checkout. Users can create additional workspaces, which are isolated copies (git worktrees) where agents work without affecting main.
You should not need to know that plugins exist to get a useful, complete experience. Extra capability should become discoverable when it helps with something you want to do.
### Inside a workspace
Keeping this starting point approachable is an ongoing responsibility. A technically correct feature can still make the product harder to understand through extra choices, controls, or setup.
A workspace is a flexible canvas:
### Room to build your own setup
- Launch multiple agents side by side in split panes
- Open terminals alongside agents
- Mix and match providers within the same workspace
The same product should also work for someone running agents on a home server, using their own network, or building an automated workflow.
### The daemon
The daemon can run independently of the desktop app. Clients connect to it locally or remotely, and multiple clients can use the same daemon. This separation lets you choose where the work runs and how you interact with it.
Paseo is a client-server system. The daemon (Node.js) runs on your machine, manages agent processes, and streams output in real time over WebSocket. Clients connect to the daemon — locally or remotely.
For example, someone can use the relay to connect their phone with minimal setup. Someone else can run a daemon on their own infrastructure and connect through SSH or a VPN. Both use the same underlying system.
This architecture means:
Paseo should provide useful defaults while keeping these choices available. You should not have to adopt every part of the system to benefit from the parts you want.
- The daemon can run on any machine: laptop, VM, remote server
- Multiple clients can connect simultaneously
- Agents keep running when you close the app
See the [connectivity guide](https://paseo.sh/docs/connectivity) for supported connection methods and [architecture](architecture.md) for the system design.
## Target user
## Freedom, ownership, and privacy
Anyone who builds software:
Paseo's flexibility depends on keeping control with the user:
- Care about owning their tools and their data
- Use multiple AI providers and want to switch freely
- Run agents on real tasks across real projects
- Want to work from multiple devices
- **Self-hosted:** Run agents on your own machines, with your own environment, files, and credentials.
- **Provider choice:** Use supported agent harnesses and extend provider support through plugins. Your workflow should be able to evolve as your choice of provider changes.
- **Cross-device:** Use desktop, mobile, web, and CLI clients according to the situation.
- **Privacy:** No Paseo telemetry, tracking, or forced account. The relay is optional and end-to-end encrypted.
- **Open source:** Paseo is Apache-2.0 licensed. You can inspect it, modify it, and build on it.
## What compounds over time
Convenience and ownership should work together. Offering a straightforward way to connect should preserve the option to operate your own setup.
- **Trust** — Showing up daily, shipping in public, being open source. Earned slowly, lost quickly.
- **Ecosystem** — Skills, integrations, shared configs. Community-built content that makes the platform more valuable.
## A lean core that keeps improving
## Strategic bets
The core provides the shared experience: starting work, interacting with agents, understanding their state, and moving between workspaces and devices.
1. **Models commoditize.** Value moves to the orchestration layer. The best model changes monthly — the workflow layer stays.
2. **Multi-provider wins.** No single provider stays on top. Developers want the best model for each task.
3. **The daemon as infrastructure.** Server/client architecture enables deployment anywhere.
4. **Open source outlasts funding.** Open source communities are resilient. Contributors become advocates.
Core development continues through improvements to reliability, performance, interaction design, and workflows with broad value. A lean core takes ongoing work.
## Current state (May 2026)
Every addition also has a lasting cost. It needs a place in the interface, an understandable relationship with existing behavior, testing across affected platforms, and maintenance as the rest of the product changes. A feature can work exactly as intended and still be the wrong addition.
- Desktop (Electron), mobile (iOS/Android), web, CLI
The preference is for a small number of well-shaped capabilities that work together. Before adding a specialized workflow, consider whether an existing capability can serve it or whether a reusable extension point would let people build it themselves.
The boundary can evolve. Some built-in capabilities may eventually make more sense as plugins. Any such move needs to preserve a coherent default experience and account for people who already depend on it.
## Extensibility and composition
Plugins are a central part of how Paseo gains capability.
Someone who needs a different agent provider, a specialized workspace panel, or a custom command should be able to build and share it independently. They can make the choices that fit their users and maintain the integration on their own schedule.
Paseo's role is to provide interfaces that make those contributions possible across the daemon and connected clients. The [plugin documentation](https://paseo.sh/docs/plugins) describes what each released API supports.
The [SDK](https://paseo.sh/docs/sdk) lets applications and services use the daemon directly. A separate dashboard, automation service, or client can build on the same agent infrastructure. Those projects do not all need to become features of the main app.
Composition means useful pieces can be combined in ways the maintainer did not have to predict. Keeping the daemon independent, offering multiple connection methods, and exposing agent operations through APIs gives people room to choose those combinations.
The extension interfaces need the same care as the core experience. They should be understandable, reusable, and maintainable. New extension points should follow concrete workflows that need them.
## How Paseo develops
Product, design, architecture, and workflow decisions remain with the maintainer.
Those decisions draw on the history of the product: why a workflow has its current shape, what previous approaches taught us, how it interacts with the rest of the system, and where it is intended to go. Documentation shares that context, but familiarity also develops through sustained use and maintenance.
Development starts with the smallest useful change that can make a substantial difference. After shipping it, leave room to learn:
- Can people discover and understand it?
- Does it work well in their actual workflows?
- What friction or missing capability appears with repeated use?
- Does the next addition improve the shared experience or belong in a plugin?
That feedback guides the next step. Expanding a workflow too far before people use it creates commitments that are difficult to reverse. Users build habits and integrations around what ships, so removing features or changing interfaces has a real cost.
Writing an implementation is one part of this process. Product judgment, design, QA, review, and learning from use determine how quickly a change can responsibly become part of Paseo.
## An ecosystem people can build on
People contribute through reproducible bug reports, testing, workflow discussions, documentation, plugins, integrations, and helping each other.
A plugin can solve a problem for its users without adding that workflow to everyone's default experience. An alternative client can explore a different interface while using the same daemon. These projects let people take ownership of the experience they want.
The aim is an ecosystem that can develop independently of the maintainer's ability to review core changes. Its value comes from useful things people build, maintain, and share.
Paseo is an independent project. Sustainable development, a coherent product, and the freedom to keep building are priorities. See [Contributing](../CONTRIBUTING.md) for how to participate.
The app and the daemon are separate products that ship separately. A user updates the app from an app store or a desktop auto-update; they update the daemon when they feel like it. Every combination happens in the wild: new app against an old daemon, old app against a new daemon, and both sides months apart.
In development both sides are always the same version, which is why this is the constraint contributors miss most often.
Two contracts follow from it.
## The protocol contract: always compatible
A schema change must not break parsing in either direction. An old app still parses messages from a new daemon. A new daemon still parses messages from an old app.
`hello` carries an optional credential. The daemon accepts a client protocol version from its minimum through newer client versions, selects the lower of client and daemon maximum, and reports it as optional `server_info.protocolVersion`. A `hello.rejected` frame precedes an auth close only when the client sent `hello.auth` or advertised `hello_rejection`; older clients receive the existing WebSocket close code and reason. This avoids sending them a new top-level message their validator does not recognize.
- New fields are `.optional()` with a sensible default.
- Never flip optional to required, remove a field, or narrow a type. `string` to `enum` and nullable to non-null are both narrowing.
- A field you stop sending stays accepted. You stop writing it, you don't stop reading it.
- Wire schemas are pure structural declarations. No `.transform()`, `.catch()`, or `.preprocess()` on WebSocket message schemas — normalization happens in an explicit pass after validation. The reason is in [protocol-validation.md](protocol-validation.md): inbound validators are generated, and the generator only compiles pure schemas.
- Plain `z.union()` is forbidden when every branch shares a literal tag. Use `z.discriminatedUnion()`.
- `.default()` belongs on primitive leaves only, never on item schemas inside large arrays or big inbound containers.
Two questions to ask before you commit a schema change:
1. Does a six-month-old app still parse this message?
2. Does a six-month-old daemon still send something this app accepts?
If you can't answer both with yes, the change isn't done.
Schemas live in `packages/protocol/src/messages.ts`. New RPC names follow [rpc-namespacing.md](rpc-namespacing.md).
## The feature contract: per feature, gated once
Features don't have to work across versions. A new feature usually needs a new daemon capability, and old daemons don't have it.
The app checks for the capability and either runs the feature or tells the user to update the host.
- **No fallback paths.** Don't build a degraded version of the feature for old daemons. Don't fan out across legacy RPCs to simulate a capability that isn't there. The user updates or doesn't get the feature.
- **No defensive branches spread through the feature.** Detection happens in one place, and everything downstream reads a clean shape.
- Capability flags live in `features` on the `server_info` message (`packages/protocol/src/messages.ts`, the `server_info` schema).
Existing functionality keeps working across versions because of the protocol contract. Gating a new feature never substitutes for that.
## Client capability ownership
The client package advertises the protocol behavior it implements. Add each new capability to
its exhaustive defaults and implement the associated subscription or decoding behavior there.
The app, CLI, and plugins inherit those defaults; they supply only host resources such as browser
automation, or explicit overrides. A schema accepting a message does not establish support for
its delivery semantics.
On capable daemons, connecting creates no timeline or event demand. Client subscriptions own their network membership,
release it on unsubscribe, and restore it after reconnect. Raw message observers inspect traffic
without requesting streams. Application caches and which agents are visible remain caller-owned.
## Owned observations
`owned_subscriptions` and `server_info.features.ownedSubscriptions` negotiate the source-owned
contract described in [architecture](architecture.md#websocket-protocol). The client selects delivery
behavior once at its connection boundary. App workflows use the same observation interface on both.
With an older daemon, the client uses the existing connection and legacy RPCs. Directory subscriptions
remain shared and last-query-wins. Local handle IDs identify listeners; they do not promise independent
server filters. Timeline and event membership retain their existing shared behavior. Releasing a handle
detaches its listener and uses the old unsubscribe operation where one exists. Broadcast-only hosts keep
broadcasting; readiness there means local attachment, not a daemon acknowledgement. No extra sockets
or multiplexing emulation are introduced.
Preserve established workflows when changing delivery internals. Independent filters and quiet
connections require a capable daemon; opening the app, reading history and using terminals do not.
Pre-registry workspace grouping and legacy event normalization belong inside the client boundary.
Old clients keep their existing wire shapes and slot behavior at the daemon's source boundary.
The adapter keys legacy slots by physical socket, so an old connection cannot replace a modern
sibling's observation even when both use the same logical client ID. Optional wire IDs stay accepted
for parsing compatibility; modern requests cannot select their subscription ID.
## Every shim is tagged and dated
A shim that exists for old-app or old-daemon support carries a comment naming it, the version it arrived in, and when it can go:
```ts
// COMPAT(workspaceFileEditing): added in v0.2.0, remove after 2027-01-18 once daemon floor >= v0.2.0.
```
`rg "COMPAT\("` is the full cleanup backlog, so:
- One tag per shim, at the site that has to be deleted.
- Give it a name, a version, and a removal condition or date. Six months out is the usual default.
- Never bury compatibility in an untagged `??` fallback or an optional-chain tunnel. Untagged back-compat never gets removed, because nobody can find it.
When a tag's condition is met, delete the shim and the tag in the same change.
## QA
Tests don't fully cover compatibility. If you touched `packages/protocol`, say in the pull request why an older app still parses your message and why an older daemon still satisfies your app. See [qa.md](qa.md).
The client validates inbound WebSocket messages with a zod-aot generated validator instead of runtime Zod on the hot path. Zod remains the authoring source of truth for schemas and TypeScript types.
The reason is mobile performance. A captured 353 KB provider snapshot cost about 10.9 ms and 5.9 MB allocated per message for `JSON.parse` plus Zod on Hermes. After moving provider-model normalization out of the schema so zod-aot could compile the hot subtree, the generated validator path measured about 2.5 ms and 1.2 MB allocated.
## Runtime Path
`packages/protocol/src/validation/ws-outbound.ts` is the shipped boundary. It calls the generated `WSOutboundMessageSchema.safeParse` and returns the validated data. It does not normalize, repair, or re-validate the generated result.
Generated validators preserve unknown keys where Zod object parsing strips them. The client dispatch path uses known `type` and payload fields, so this passthrough behavior is accepted for inbound messages. The wire format is unchanged.
Provider model normalization is a parser-side compatibility shim in the client consumers that need it. Newer daemons normalize at the provider registry source.
## Codegen Ownership
The protocol package owns generation.
- `packages/protocol/codegen/ws-outbound.compile.ts` is the build-time zod-aot discovery entry.
- `packages/protocol/scripts/generate-validation-aot.mjs` runs the exact-pinned compiler and applies the small local compiler patches before generation.
- `packages/protocol/scripts/watch-validation-aot.mjs` reruns generation while editing protocol sources.
- `packages/protocol/src/generated/validation/ws-outbound.aot.ts` is generated runtime code and is gitignored.
- `packages/protocol/src/validation/ws-outbound-schema-metadata.ts` is runtime schema metadata for zod-aot fallback/default references.
Generation runs from protocol-owned lifecycle hooks: `prebuild`, `pretypecheck`, `pretest`, and `watch`. Installs do not run generation: published packages consume protocol from prebuilt `dist`, and local build/typecheck/test flows generate the source file at the point it is actually needed.
## Regression Tests
zod-aot is exact-pinned and young enough that compiler patches are treated as part of this package. `packages/protocol/tests/validation/ws-outbound.test.ts` keeps small regression tests for the patched cases:
- discriminated-union branch output must propagate `.default()` fields
- current sequential item routing must accept `tool_call`-like status branches
- generated runtime imports must keep `.js` extensions for packaged Node ESM
- the generated WebSocket envelope accepts a minimal valid message and rejects a corrupted one
## Schema Purity
Message schemas are structural declarations. Do not put `.transform()`, `.catch()`, or `.preprocess()` on WebSocket message schemas. If parsed data needs normalization, put it in an explicit consumer or post-validation pass.
Use `z.discriminatedUnion()` when every branch has a shared literal tag. Plain `z.union()` is acceptable only when there is no shared literal discriminator or when a generated-code regression test proves that specific shape is miscompiled.
Defaults are allowed only on primitive leaves. Do not place `.default()` on large arrays, item schemas, or big containers in inbound message schemas.
| Muse Code | MSP over one `muse serve` host per session | [Muse Code](../public-docs/muse-code.md) |
## Provider-native session options
The provider owns validation and application of the opaque record in
`AgentSessionConfig.providerOptions`. The registry supplies the effective options
at session startup. See [provider configuration](custom-providers.md#provider-options)
for defaults and merge rules, and the [SDK guide](../public-docs/sdk/provider-options.md)
for native keys and examples.
Exact MCP preapproval is a separate daemon-owned contract. A new provider must fail
closed for Hub unattended execution until it can approve one exact injected MCP
server and tool identity without approving native tools.
## Core adapter patterns
### ACP (Agent Client Protocol) -- recommended
@@ -10,47 +42,159 @@ Extend `ACPAgentClient` from `packages/server/src/server/agent/providers/acp-age
The only built-in ACP provider today is `copilot` (`copilot-acp-agent.ts`). `GenericACPAgentClient` (`generic-acp-agent.ts`) is also ACP-based but is used for user-defined custom providers configured via `extends: "acp"` overrides — see [docs/custom-providers.md](custom-providers.md).
Copilot custom agents are exposed through ACP session config, not the slash-command list. When custom agents are available, Copilot returns a select config option with `id: "agent"` and `category: "_agent"`; Paseo maps that to the `agent` provider feature. Copilot uses the agent display name as the option value, and the blank value means the default Copilot agent.
ACP permission options are rendered as ordered actions and Paseo returns the selected option's exact `optionId`. Agents can therefore encode a single-choice question as multiple options of the same allow kind. Auto-accept does not resolve those chooser requests; they always wait for the user.
ACP shims can own model discovery through `catalogModelResolver`; the shared client owns the probe
process and refresh deadline. Keep vendor RPCs in the shim. Cursor uses
`cursor/list_available_models` because switching models during discovery writes its saved CLI
preferences and selection history. Cursor versions without that extension must be updated. Kimi
still probes model selections in its own shim. The initial session supplies modes and the current
model; it does not override the model list returned by a resolver.
### Direct
Implement the `AgentClient` and `AgentSession` interfaces from `agent-sdk-types.ts` yourself. This gives full control but requires you to handle process management, streaming, permissions, and session persistence from scratch.
Existing direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`codex-app-server-agent.ts`), `opencode` (`opencode-agent.ts`), `pi` (`providers/pi/agent.ts`), and `omp` (a Pi-compatible built-in backed by the Pi adapter). The dev-only `mock` provider (`mock-load-test-agent.ts`) is also direct.
Core direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`codex-app-server-agent.ts`), `opencode` (`opencode/runtime-client.ts`), `pi` (`providers/pi/agent.ts`), and `omp` (`providers/omp/agent.ts`). The dev-only `mock` provider (`mock-load-test-agent.ts`) is also direct.
Claude first-party model metadata lives in `packages/server/src/server/agent/providers/claude/model-manifest.ts`. When adding or updating a Claude model, update that manifest only; the model picker thinking options and Claude-specific feature gates are derived from the manifest. Do not add model-specific Claude capability lists in feature code.
Keep the curated Claude catalog: `supportedModels()` has historically omitted older versions and carries less metadata. Discovery is only an opt-in visibility gate, not a replacement catalog or an entitlement guarantee. Claude Code v2.1.197 added `resolvedModel` to disambiguate alias rows; a bare family alias does not establish access to a particular version. Explicit user-configured models must remain selectable when discovery fails or omits them.
Paseo tools are not implemented as MCP tools internally. They live in a shared tool catalog under `packages/server/src/server/agent/tools/`; MCP is only the fallback adapter. The daemon resolves `agents.providers.<provider>.paseoTools` by the exact provider ID. The catalog policy belongs to the caller: it filters the tools exposed to the current agent. When that agent calls `create_agent`, the child receives the policy for the child provider ID; the caller's policy is not inherited.
A provider that can register runtime tools directly should set `supportsNativePaseoTools: true` and consume the already-filtered `launchContext.paseoTools` in `createSession`/`resumeSession`. When native tools are present, `AgentManager` strips the internal Paseo MCP server from the provider launch config so the provider does not receive the same tools twice. Providers that only know MCP should keep `supportsMcpServers: true` and let the daemon inject `/mcp/agents`; the MCP server builds the same policy-filtered catalog for that caller. Filtering is enforced at catalog registration in both paths. Browser tools remain subject to the daemon browser-tools setting and browser-host availability.
Pi is a process-backed provider. Paseo requires the user to have the `pi` binary installed and talks to it through `pi --mode rpc`; the server package does not embed Pi's SDK/runtime packages.
Paseo's per-agent and daemon-wide system prompts are passed to Pi with `--append-system-prompt`, so Pi keeps its default coding prompt while receiving Paseo's additional instructions.
Pi extension adapters live under `packages/server/src/server/agent/providers/pi/extensions/<extension>/`. Each adapter turns Pi RPC facts into Paseo tool, timeline, subagent, or question mappings through the [extension contract](../packages/server/src/server/agent/providers/pi/extensions/contract.ts). To add one, create its directory, add one entry to `extensions/registry.ts`, and test it with fixtures captured from the real extension in Pi that record package, version, source commit, Pi version, and capture date. `agent.ts`, `history-mapper.ts`, and `tool-call-mapper.ts` never name an extension.
Pi MCP support depends on the open-source `pi-mcp-adapter` extension being loaded for the agent cwd. Probe with Pi RPC `get_commands`; the adapter registers an extension command named `mcp` (often with `sourceInfo.source` containing `pi-mcp-adapter`). When Paseo injects MCP servers into Pi, write a per-agent MCP config and pass it with `--mcp-config` instead of modifying user or project MCP files. For local HTTP servers such as Paseo's own `/mcp/agents` endpoint, explicitly disable adapter OAuth (`auth: false`, `oauth: false`) in the generated config.
Paseo's per-agent and daemon-wide system prompts are appended by its generated Pi integration extension. Paseo deliberately does not pass `--append-system-prompt`, because that flag replaces Pi's automatic `APPEND_SYSTEM.md` discovery instead of composing with it.
Pi model records expose input capabilities through `model.input`. Only send raw RPC `images` when the current model explicitly includes `"image"` in that list. Text-only Pi/OMP models reject image content and persist the rejected image in JSONL history, so image prompts for those models must be materialized to a local file and passed as a text path hint instead.
Probe Pi MCP support with Pi RPC `get_commands` for the agent cwd. Pi 0.99 and later ship MCP as a built-in extension, which registers an extension command named `mcp` with `sourceInfo.path``builtin:mcp`. Register injected servers through `pi.registerMcpServer` in Paseo's generated extension; the built-in extension rejects SSE servers and names with characters other than letters, digits, `_`, and `-`, and a server of the same name in Pi's `mcp.json` takes precedence.
The open-source `pi-mcp-adapter` extension replaces the built-in one and registers its own `mcp` command (often with `sourceInfo.source` containing `pi-mcp-adapter`). When it is loaded, write a per-agent MCP config and pass it with `--mcp-config` instead of modifying user or project MCP files. Because that flag replaces the Pi global config layer, preserve the existing `<Pi agent dir>/mcp.json` in the generated file before overlaying injected servers. For local HTTP servers such as Paseo's own `/mcp/agents` endpoint, explicitly disable adapter OAuth (`auth: false`, `oauth: false`) in the generated config.
Pi control-plane RPCs wait 60 seconds by default. Override `params.rpcTimeoutMs` when extension or MCP startup on a slow host needs more time. Timeout errors name the pending RPC phase and report both elapsed time and the configured deadline. This setting does not govern long-running Pi compaction or Pi extension UI results. See [OMP profiles and Pi-compatible forks](custom-providers.md#omp-profiles-and-pi-compatible-forks) for OMP startup and RPC deadlines.
Pi import discovery reads Pi's persisted JSONL session files because Pi RPC does not expose a recent-session listing command. Resume and full history hydration still go through `pi --mode rpc` using the session file as `nativeHandle`.
OMP is a built-in Pi-compatible provider, disabled by default. It uses the `omp` command and imports terminal-started sessions from `~/.omp/agent/sessions` when enabled. Other Pi-compatible forks can still be custom providers that extend `pi`, override `command`, and set `params.sessionDir` to their JSONL session directory.
OMP is a first-class built-in provider, disabled by default. Its launch contract, typed runtime, agent/session behavior, history, permissions, imports, and test fake live under `providers/omp/`; only the provider-neutral JSONL child-process transport is shared with Pi. It launches `omp --mode rpc-ui`, uses OMP's `get_available_commands` RPC for slash-command discovery, bridges OMP `rpc-ui` approval dialogs into Paseo permissions, and imports terminal-started sessions from `~/.omp/agent/sessions` when enabled.
OMP supports native Paseo host tools. The adapter registers the full caller-scoped Paseo tool catalog directly with OMP, matching providers such as Claude that expose the full catalog through MCP. Serialize every OMP host definition with `loadMode: "essential"` so `create_agent`, `send_agent_prompt`, `wait_for_agent`, and related tools remain direct calls; omitting the field makes OMP mount non-built-in names under `xd://` instead. OMP's provider-managed task subagents are surfaced as Paseo subagents through `child_session` imports; the parent keeps the subagents track while the child runtime stays owned by OMP. Custom OMP profiles should extend `omp`; other Pi-compatible forks can still extend `pi`, override `command`, and set `params.sessionDir` to their JSONL session directory.
Pi RPC extension UI dialog requests (`select`, `input`, `editor`, `confirm`) are bridged into Paseo question permissions and answered with `extension_ui_response`. Pi extensions such as `ask_user` may chain dialogs: for example, a `select` can be followed by an optional-comment `input`. When an `ask_user` tool call declares `allowComment: true`, Paseo presents the selection and optional comment as one question permission, answers Pi's initial `select` immediately, then auto-answers the follow-up optional `input` with the comment the user already supplied (or an empty string). Preserve placeholders and optional/skip semantics for standalone optional inputs so the app can still distinguish "skip this optional input" from "cancel the whole dialog." Fire-and-forget extension UI requests such as notifications are intentionally ignored by the provider adapter unless Paseo grows first-class UI for them.
OpenCode MCP injection is dynamic and session-scoped. Call OpenCode's `mcp.add` endpoint with the MCP server config and do not follow it with `mcp.connect`; `connect` only toggles MCP servers already present in OpenCode's own config. New OpenCode versions return `McpServerNotFoundError`/404 for `connect` after a dynamic add because the server is not config-backed, while older versions silently swallowed the same missing-config path.
OpenCode adapters target v1.14.46 and v2.0.10. V2 rejects binaries older than the tested 2.0.10 SDK at runtime selection. Runtime selection uses the configured command and environment. A recognized version is cached until provider configuration reload; a failed, timed-out, or unrecognized probe retains the v1 path and retries detection on the next operation. Load v2 code and materialize its plugin only after positive v2 selection; v1 sessions remain undecorated. Keep upstream SDK types inside the version-specific adapter. OpenCode owns storage migration; a missing native session must fail resume rather than create a replacement. V2 has no native archive/unarchive operation: archiving affects Paseo only. V1 retains native archiving.
Use OpenCode v2 execution events to trigger turn completion, with active-state and durable-log reconciliation after admission, reconnect, and while a turn remains active. Do not use `session.wait`: a healthy turn exceeding Node's HTTP headers deadline produces a transport error while OpenCode keeps working. The live event feed has no replay, and shutdown interruption preserves the previous idle outcome, so the session snapshot alone cannot recover missed execution events. Quiet streams are healthy: v2 heartbeats are SSE comments, not application events.
V2.0.4 also removed the activation endpoint that gated a cold location, and a cold location registers its config-derived commands, skills, and providers asynchronously. Wait until `plugin.list` returns a populated inventory before reading the catalog or commands; an empty inventory means the location is still warming. Fail when the readiness deadline expires, including when an inventory request stalls.
Paseo installs its OpenCode tool bridge through `OPENCODE_CONFIG_CONTENT`. V1 accepts a plugin file; v2 silently skips configured files and requires a package directory with a server entry point. Both versions use the daemon's private loopback bridge for caller-scoped tools. Bridge context lives only in daemon memory and is removed when the Paseo session closes. The content-addressed plugin artifacts contain no session data or secrets. V2 also needs this plugin when native Paseo tools are disabled: its prompt API has no structured-output format, so the plugin supplies a schema-validated final-answer tool.
An agent with custom environment variables or user-configured MCP servers gets a dedicated OpenCode server. V2 supports session environment, but its MCP configuration remains location-scoped, so separate servers still prevent one agent from changing another agent's MCP setup. Configure custom MCP with `mcp.add`; do not follow it with `mcp.connect`, which only toggles config-backed servers.
OpenCode owns user message IDs. Do not pass Paseo-generated IDs to OpenCode prompt APIs; let OpenCode create `msg*` IDs and record the user timeline item from the `message.updated` event.
Every provider adapter owns its canonical user-message timeline rows. When a foreground prompt is accepted, the adapter must emit exactly one `user_message`timeline item for that submitted prompt, using the same message ID it gives to or receives from the provider runtime. Optimistic client messages are UI-only and provider transcript echoes are optional; neither is allowed to be the only source of truth. If the provider later echoes the same submitted user message, dedupe by provider-visible message ID, not by text.
`AgentManager` owns the one canonical timeline row for a foreground prompt carrying a Paseo `clientMessageId`. It records that row when `startTurn` accepts, with the wire `messageId`set to the same value. Provider adapters still emit their native user-message echo with the same `clientMessageId` when available; the manager records its provider identity on the internal row without changing or redispatching the wire item. If an adapter emits the echo before `startTurn` resolves, the manager records the provider identity with the row at acceptance. Provider adapters continue to own externally initiated user rows that have no Paseo client identity. Do not perform global transcript text dedupe.
Draft metadata lookups should avoid creating provider sessions when the upstream provider has top-level APIs for that metadata. Prefer `AgentClient.listModels`, `listModes`, `listCommands`, or `listFeatures` over creating a scratch `AgentSession`; scratch sessions can show up as empty native sessions in provider import/history UIs.
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()`. 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.
Rewind accepts the canonical wire `messageId` and resolves it to the provider identity before calling the adapter. A submitted prompt cannot be rewound until its provider echo supplies that identity.
Submitted user-message wire items carry the same Paseo ID in `messageId` and `clientMessageId`. Provider adapters attach `clientMessageId` only to the echo for that foreground submission; provider history and externally initiated user rows do not have a Paseo client ID.
Provider adapters must terminalize every transient timeline row before emitting the turn's terminal event. Codex may omit the completed `contextCompaction` item when a turn ends during compaction, so its adapter closes any pending root compaction before forwarding `turn_completed`, `turn_failed`, or `turn_canceled`. A terminal turn must never leave the client showing an operation as still loading.
Draft metadata lookups should avoid creating provider sessions when the upstream provider has top-level APIs for that metadata. Prefer `AgentClient.fetchCatalog`, `listCommands`, or `listFeatures` over creating a scratch `AgentSession`; scratch sessions can show up as empty native sessions in provider import/history UIs. `fetchCatalog` is the single discovery API for models and modes — provider implementations may use one process, separate upstream calls, or static data internally, but callers outside the provider do not get separate runtime model/mode probes. Draft command listing and scratch-session feature listing require an explicit draft model. Do not resolve a default model through catalog discovery. A client-level `listFeatures` implementation may return features from an incomplete, model-less draft and owns which features are valid in that state.
Provider session import has its own contract. The picker calls `listImportableSessions` and receives rows only: provider handle, cwd, title, prompt previews, and last activity. Import calls `importSession({ providerHandleId, cwd })` for the selected row and must not call listing again. The provider returns the resumed session, storage config, persistence handle, and hydrated timeline for that one native session; `AgentManager.importProviderSession` seeds the daemon timeline and publishes the Paseo agent only after it is ready.
## Provider Helper Processes
Provider-owned helper processes that can outlive an individual agent session must be recorded in the daemon's managed-process registry. Store provider/kind metadata, the PID, launch command/args, and process identity captured from the platform process table. Remove the record on normal exit or shutdown.
If a helper process has a readiness phase, the provider's lifecycle model must own the process immediately after `spawn`, before readiness succeeds. Startup timeout, startup exit, and daemon shutdown must all clean up through that owned generation. Do not keep a spawned helper only inside a readiness promise; that creates a live process outside the manager/reaper contract.
Daemon bootstrap reconciles that ledger in the background, without blocking startup: dead PIDs are deleted, PID identity mismatches are deleted without killing anything, only positively matched Paseo-owned leftovers are terminated, and a record whose process cannot be inspected is left in place for the next reconcile rather than deleted. Do not add broad process-name sweepers for provider cleanup; cleanup starts from records Paseo previously wrote.
---
## Provider Snapshot Refresh Contract
The daemon keeps provider snapshots per resolved working directory. Missing or blank cwd resolves to the user's home directory. Workspace selectors and old model/mode list requests should pass the cwd that will launch the provider so providers with project-specific models or modes are probed in the right context. Settings/provider management intentionally uses the home-directory snapshot.
Provider snapshots are views of the catalogues needed for a target. Before looking up cached
results or discovery in flight, the manager calls optional `AgentClient.getCatalogCacheKey(options)`.
The provider owns equivalence: equal keys must mean the same availability, models and modes,
including the effective configuration and execution environment. `force` does not change identity.
Omitting the method or returning `undefined` keeps target-specific caching. Codex and Claude's
current host clients share across directories; configured provider identities remain isolated.
Snapshot reads may probe providers only while the requested cwd scope is cold. Once an entry is warm, its `ready`, `error`, or `unavailable` state stays cached until an explicit refresh. Do not add TTL revalidation, focus-triggered refreshes, selector-open refreshes, or config-reload refreshes. Selector-open refetches may read an already-loading or stale React Query, but they must not force provider probing on their own.
The key chooses storage, never execution. Availability and catalogue discovery receive the actual
`{ scope: "global", force }` or `{ scope: "workspace", cwd, force }` request, including when another
target can share its result. Runtime-aware adapters must use that target for both probing and key
resolution. An explicit home-directory workspace is distinct from a semantic global request.
Plugin callbacks follow the same contract; see [plugin providers](plugins.md#contribute-a-provider).
Settings refresh is the user-facing "forget stale provider knowledge everywhere" action. A settings refresh clears provider snapshot caches and in-flight loads across all cwd scopes, then immediately refreshes only the home-directory snapshot with `force: true`. Workspace snapshots are re-probed lazily on the next scoped read; do not fan out a settings refresh across every known workspace.
`ProviderSnapshotManager` owns one refresh deadline per provider. The deadline starts before the
availability check and covers that check plus the complete catalog probe. Providers that make
multiple catalog requests must not apply this deadline separately to each request. The manager
aborts the shared refresh signal at the deadline. Providers name active catalog operations and
finish subprocess, server, or session cleanup before rejecting. Timeout errors list the operations
that were still active when the deadline expired.
Registry/config replacement may update visible metadata such as label, description, default mode, enabled state, and provider membership, but it must not spawn provider processes. If a provider needs to be re-probed after a config change, route that through the explicit settings refresh path.
Catalogue results stay cached by identity until explicit refresh or a change to that provider's configuration.
Keys are resolved on each read so project configuration can select a different cached catalogue.
Selector opening may read a loading or stale query, but does not force provider probing.
Boundary tests should assert observable behavior: cold reads may call provider availability/model/mode discovery for that cwd; warm reads and registry replacement must not; explicit workspace refreshes affect only one cwd; settings refresh wipes all scopes but immediately refreshes only home.
Saved provider/model choices are user intent. Catalogue failure must not erase them or substitute
another model. Creation reads the caller's host and directory directly; an earlier global snapshot
must not settle a project form's initial selection.
The server's provider keys never cross the wire. Clients advertising `provider_snapshot_references`
receive directory-to-hash announcements; an unknown hash uses the existing snapshot request.
The manager detaches each fresh result once and publishes its content identity with the entry;
readers share these values read-only. Each target keeps its published snapshot until membership,
content or discovery freshness changes. All affected targets commit before subscribers run;
subscriber failures are logged without changing discovery or configuration outcomes.
The session compares both sides of a committed transition under the client's current visibility
policy and sends only visible changes. It combines result identities with the client's encoding
and icon policy, without traversing models. Reference hashes exclude freshness;
legacy embedded hashes include it. Only responses that send a body compact the catalogue.
Per-provider `fetchedAt` travels separately for reference clients and still means when discovery succeeded. `generatedAt`
remains the time the response or announcement was generated. Refresh keeps settled entries visible
until the next result, so unchanged discovery sends freshness without another model body.
The app's snapshot cache owns compact expansion and stores one body per server/hash, with separate
directory/hash/freshness records under the same byte budget. Missing bodies share a React Query
keep expanded entries and full updates. Only callers that own materialization opt into wire snapshots.
Keep the full encoding for older clients; compact-snapshot support alone does not imply reference support.
Settings refresh invalidates requested providers across known targets and refreshes them in their
actual execution context, so every connected client receives the refreshed view. Discovery shares
work by provider key and admits at most four concurrent catalogue probes per configured provider, so a stalled provider does not block discovery for others. Configuration replacement invalidates
only changed providers; unchanged entries, clients, and discovery in flight survive. Preparation
identity, so unchanged registrations and builtins keep their results. Await the refresh or warmup
promise for completion: equal results, including equal discovery timestamps, emit no transition.
---
## Usage sources
See the [public usage source reference](../public-docs/plugins/reference.md#usage-sources) for the
contract, account and window identity, provider-derived period names, login fallback, and
read-only credential rules. Usage adapters own the interpretation of provider fields; the app
renders their names and resolves pins without provider-specific duration guesses.
---
@@ -138,7 +282,7 @@ export class CopilotACPAgentClient extends ACPAgentClient {
### 2. Add to the provider manifest
In `packages/server/src/server/agent/provider-manifest.ts`, add mode definitions with UI metadata (icons, color tiers) and a provider definition entry.
In `packages/protocol/src/provider-manifest.ts`, add mode definitions with UI metadata (icons, color tiers) and a provider definition entry.
`setMode` and `setThinkingOption` may return an `AgentProviderNotice` when the provider knows the change needs user-facing context. For example, providers that stage changes until the next turn should return an `info` notice while a turn is already running. The app renders the notice generically as a toast; provider-specific lifecycle behavior stays in the provider implementation.
### Steps
1. Create `packages/server/src/server/agent/providers/{name}-agent.ts` implementing both interfaces
@@ -420,3 +566,11 @@ Tests use `isProviderAvailable(provider)` to skip when the binary or credentials
**`defaultCommand` is a tuple.** The first element is the binary name, the rest are default arguments. The base class uses this to find the executable and spawn the process.
**Runtime settings can override the command.** Users can configure custom binary paths or environment variables per provider via `ProviderRuntimeSettings`. Your factory in the registry should pass `runtimeSettings?.["your-provider"]` through to the constructor.
**Session-scoped cancellation needs a stop boundary inside the provider.** Some agents cancel the whole session rather than one turn — OpenCode's `session.abort` is the example. A cancel that is still in flight when the next run starts will kill that replacement run, which is what makes "stop, then immediately prompt again" (`replaceRunning`, `notifyOnFinish` wakes, schedules) flaky. Own this in the provider session, not in `AgentManager`:
- Model the stop as an explicit `stopping` turn-state variant carrying the canceled run's terminal and the cancellation the caller is still owed. Pressing Stop again retries the stop already in progress rather than opening a second one; never fire a detached retry, it will outlive its boundary.
- **Scope the cancel settlement the way the provider scopes the cancel.** If cancellation is session-scoped, so is its settlement: track it on the session, accumulating every request issued, and let it outlive the stop that issued it. A request still in flight lands on the runner whenever the server gets to it — however many stops have come and gone since. Scoping it to the current stop looks right and quietly drops older requests from the gate. Only the newest may hold the gate closed, since recovering from a failed cancel is what pressing Stop again is for.
- Gate the operations **the daemon issues** (prompt, slash command, summarize) on both the terminal and cancel settlement. Permission and question responses are not runner operations and must stay outside the gate, or an auto-approve deadlocks the stop. Runs the _provider_ starts on its own — plugin or autonomous wakes — are observed, not gated: the daemon does not choose when they begin, and holding their events back does not protect them from a cancel already in flight, it only hides a run that may already be dead.
- Fail closed: if the cancel never succeeded you never proved the run stopped, so refuse new runs until the next Stop issues a fresh cancel. `AgentManager` already turns a rejected `interrupt()` into a refused cancel.
- Suppress the canceled run's residue only until its authoritative terminal. Anything the provider publishes after that terminal is a new run by construction and must take the normal live path — buffering it and replaying it later is how autonomous/plugin wakes get lost.
QA is the main bottleneck of Paseo's product development.
The bar is four questions:
1. Does it work well?
2. Does it regress anything else?
3. Was it tested on every platform it affects?
4. Is there automated coverage, and is that coverage real?
Every pull request needs evidence for the questions its change touches. Pull requests without evidence get closed.
For plugin changes, verify the [SDK import boundaries](plugins.md#sdk-import-boundaries) before
exercising contributions. Include the boundary tests and both runtime loaders in the evidence.
## Evidence
Evidence is something someone else can look at:
- The commands you ran, pasted with their output
- The tests you added and their results
- Before and after screenshots
- A video of the interaction
- Logs, requests, responses
Redact what you need to, keep the technical details. If an agent did the work, submit its raw output. A summary drops the details someone else needs to check it.
## Does it work well
A feature that works but stutters, shifts the layout, or misaligns by two pixels is not done.
For UI, that means matching the design system in visuals, in interaction, and in performance. [design.md](design.md) is the reference.
The two things that go wrong most often:
- **Layout shift.** Content that jumps as data arrives, a row that resizes when a badge appears, a panel that reflows on first render. Watch the change on a slow connection and on first load, not just after everything is warm.
- **Alignment.** Things align to their glyphs, not to their touch targets. See the alignment rules in [design.md](design.md).
## Does it regress anything else
Paseo is composable by design, which means your change sits next to features you didn't touch. Open the surfaces around it. A change to the agent list affects archive, subagents, and tabs; a change to git actions affects worktrees and the checkout flow.
Performance is part of this. The app is Expo React Native, not a web app in a native shell. You are not writing CSS, styles resolve differently, and performance characteristics differ per platform. What feels instant in a desktop dev build can be visibly slow on a phone.
If your change touches a hot path such as the terminal, the message list, or git polling, submit before and after numbers. [terminal-performance.md](terminal-performance.md) has the terminal pipeline and its benchmarks, and [development.md](development.md) covers renderer and React profiling.
## Every platform it affects
Your code does not only run on the platform you tested it on. The same app ships to iOS, Android, browser web, and Electron on macOS, Windows, and Linux, and the daemon runs on all three desktop operating systems plus Docker.
You aren't expected to own every device. You are expected to say what you covered:
| Platform | Tested | Notes |
| --------------- | ------ | ----- |
| iOS | | |
| Android | | |
| Web | | |
| Desktop macOS | | |
| Desktop Windows | | |
| Desktop Linux | | |
Install what you reasonably can. An iOS simulator and an Android emulator cover most of the gap on a single machine, see [development.md](development.md) and [android.md](android.md).
For the rules about which code runs where, read the platform gating section in [CLAUDE.md](../CLAUDE.md). The recurring traps have their own docs: [hover.md](hover.md), [unistyles.md](unistyles.md), [floating-panels.md](floating-panels.md), [mobile-panels.md](mobile-panels.md), [expo-router.md](expo-router.md).
App and daemon versions also drift, in both directions. That has its own contract: [protocol-compatibility.md](protocol-compatibility.md).
## Automated coverage that means something
Tests are evidence only when they exercise the real thing.
- A bug fix needs a regression test that fails on the old code for the reported reason.
- A feature needs tests that go through the same interface a user or a caller uses.
- Web flows need an actual Playwright run, not a mocked-out approximation of one.
- Agent provider work needs manual verification against the real provider by someone who can tell that it worked. A fixture and a mock prove the fixture parses, nothing more.
Tests that mock away the behavior, assert on internals, or pass against the broken code claim coverage that isn't there.
[testing.md](testing.md) is the standard, including how to run suites without freezing your machine. For driving a real daemon in a test, see [ad-hoc-daemon-testing.md](ad-hoc-daemon-testing.md). For mobile flows, [mobile-testing.md](mobile-testing.md). For Electron screenshots, [browser-capture-harness.md](browser-capture-harness.md).
@@ -13,20 +13,28 @@ Each domain becomes a controller class in its own file with the **exact** contra
Session shrinks to a connection/dispatch shell: it keeps `handleMessage`, the `??` chain (1739-1751), `emit`/`emitBinary`, `sessionLogger`, connection identity, inflight metrics, lifecycle intents, and the **ordered**`cleanup()`. Each `dispatchXMessage` collapses to `return this.xController.dispatch(msg)`.
## Progress (shipped — diverged from the original filenames)
The first carves shipped as **deep modules with a narrow Host seam**, not the `dispatch(msg)`-owned-set controllers sketched below: `session.ts` keeps each `dispatchXMessage` switch and delegates per case to the subsystem. Home convention that emerged: session subsystems live at **`session/<domain>/`**, with `session.ts` as the orchestrator shell.
- **#1640 — VoiceSession** (`session/voice/voice-session.ts`, seam `VoiceSessionHost`): the STT/TTS/dictation/turn-detection subsystem. _(Originally landed at `server/voice/`; relocated under `session/` so all session subsystems share one home.)_
- **#1644 — CheckoutSession, read side** (`session/checkout/checkout-session.ts`, seam `CheckoutSessionHost`, port `CheckoutDiffSubscriber`): status, branch validate/suggest, diff subscribe/unsubscribe, manual refresh. The workspace-git observer already delegates `emitStatusUpdate`/`scheduleDiffRefresh` to it.
- **ScheduleSession** (`session/schedule/schedule-session.ts`, seam `ScheduleSessionHost`): scheduled-agent request handling.
**Next carve — CheckoutSession mutation side (Slice 4 below).** The 17 checkout _write_ handlers still inline in `session.ts` (`dispatchCheckoutMessage`, ~2010) — branch switch/rename, commit, merge, merge-from-base, pull, push, PR create/merge, github set-auto-merge/get-check-details, PR status, PR timeline, github search, stash save/pop/list — move into the existing `session/checkout/checkout-session.ts` behind `CheckoutSessionHost`. The Slice-3 observer entanglement the table fears is already resolved: #1644 moved the status/diff read side into CheckoutSession, so the workspace observer delegates today and this no longer blocks on the WorkspaceController split.
### Why this is safe at the dispatch seam (verified)
`dispatchInboundMessage` builds `a() ?? b() ?? ... ?? dispatchMiscMessage()` and short-circuits on the first non-`undefined`**Promise object** (not its resolved value). Message-type spaces are **disjoint** (no duplicate `case` labels across switches), so at most one dispatcher matches any message — collapsing to delegation cannot change which handler runs. `dispatchTerminalMessage` (2150-2153) already proves this. Two quirks preserved verbatim: schedule/\* is reached via the chat dispatcher's OWN `default` arm (2183), not the top-level `??`; and`start_workspace_script_request` (a workspace type) is special-cased before terminal delegation (2150).
`dispatchInboundMessage` builds `a() ?? b() ?? ... ?? dispatchMiscMessage()` and short-circuits on the first non-`undefined`**Promise object** (not its resolved value). Message-type spaces are **disjoint** (no duplicate `case` labels across switches), so at most one dispatcher matches any message — collapsing to delegation cannot change which handler runs. `dispatchTerminalMessage` (2150-2153) already proves this. `start_workspace_script_request` (a workspace type) is special-cased before terminal delegation (2150).
Rejected alternatives: **feature-module** (free functions + wide context bag) cannot own the live state machines (workspaceUpdatesSubscription, agentUpdatesSubscription, ~25 voice fields) and adds a competing idiom; **mixin-composition** preserves the shared-`this` god object verbatim and requires widening ~325 private fields to protected.
## Slice ordering (least-coupled first)
The task recommended **git/checkout as the first slice — OVERRIDDEN.** Verification: `emitCheckoutStatusUpdate` is called from exactly ONE site (session.ts:4915), inside the workspace-owned `syncWorkspaceGitObserver` callback that ALSO fires workspace effects over shared watch-target maps. Extracting checkout first forces splitting the hardest workspace/git seam before workspace is touched. The strictly safer first cuts are **chat-schedule-loop** (only knot: `handleChatPostRequest`; touches no shared observer/git/voice state) and**provider-catalog** (one shared collaborator + injected predicates).
The task recommended **git/checkout as the first slice — OVERRIDDEN.** Verification: `emitCheckoutStatusUpdate` is called from exactly ONE site (session.ts:4915), inside the workspace-owned `syncWorkspaceGitObserver` callback that ALSO fires workspace effects over shared watch-target maps. Extracting checkout first forces splitting the hardest workspace/git seam before workspace is touched. The safer first cut is**provider-catalog** (one shared collaborator + injected predicates).
| 0 | Test net + disjointness tripwire (no extraction) | M | low |
| 1 | ChatScheduleLoopController — **STOP FOR REVIEW after green** | M | low |
| 2 | ProviderCatalogController | M | medium |
| 3 | Split shared workspace-git observer + agent-subscribe fan-out (no controller) | M | high |
| 4 | GitCheckoutController | L | medium |
@@ -38,34 +46,6 @@ The task recommended **git/checkout as the first slice — OVERRIDDEN.** Verific
---
## Slice 0 — Test net + disjointness tripwire (prerequisite)
No production code moves. Add `session.dispatch-seam.test.ts`. This is the gate the whole plan rests on, because chat/schedule/loop have **zero** handleMessage coverage today (verified).
Write RED-then-GREEN against the **current in-place** Session:
- `chat/post` happy path (asserts `chat/post` response emitted) + fanout-limit error path (asserts the `chat/post` error envelope, NOT a bubbled `rpc_error`).
- one `schedule/*` and one `loop/*` round-trip.
- a handler that throws **synchronously** emits `rpc_error{code:"handler_error"}` + an `activity_log` error frame.
- a handler that **rejects async** emits the SAME pair.
- a table-driven assertion that the union of all controllers' owned-type `ReadonlySet`s is pairwise disjoint and covers the dispatched `SessionInboundMessage` union (grows as controllers land).
## Slice 1 — ChatScheduleLoopController ← STOP FOR HUMAN REVIEW after this ships green
**Move:** all 21 handlers (`handleChat*`×7, `handleSchedule*`×9, `handleLoop*`×5), the three rpc-error emitters (`emitChatRpcError`/`emitScheduleRpcError`/`emitLoopRpcError` — **kept separate, not merged**), `toScheduleSummary` → `packages/server/src/server/chat/chat-schedule-loop-controller.ts`. Collapse `dispatchChatScheduleLoopMessage` + `dispatchScheduleMessage` to `return this.chatScheduleLoopController.dispatch(msg)`.
**SessionContext surface:** `emit`, `sessionLogger`, `clientId` (authorAgentId fallback), `chatService`, `scheduleService`, `loopService`, and a narrow agent-control port `{ listAgents, resolveAgentIdentifier, agentStorage.list }` for `handleChatPostRequest` mention fanout.
**Owned-type set MUST include all 7 `chat/*` + 5 `loop/*` + 9 `schedule/*` types** — schedule/\* is currently routed via the chat dispatcher's own `default` arm, so it must stay inside this one controller, or schedule requests silently no-op.
**Behavior note:** least-coupled domain. Move the three rpc-error emitters verbatim (they differ in default code + the `ChatServiceError` branch). **Tests:**`session.dispatch-seam.test.ts`, `loop-service.test.ts`, `session.test.ts`.
---
## Slice 2 — ProviderCatalogController
**Move:** 7 provider handlers + `emitProviderDisabledResponse` + `getProviderSnapshotEntryForRead` → `packages/server/src/server/provider/provider-catalog-controller.ts`. Move the `providers_snapshot_update` PUSH wiring (1235-1254) into the controller's `start()`/`dispose()`. Collapse `dispatchProviderMessage`.
@@ -117,7 +97,7 @@ In-place, separately reviewable. Split the `audio_output` TTS-debug branch out o
## Slice 7 — VoiceSessionController (XL)
**Move:** voice handlers + ~25 voice fields + the TTS-debug hook (Slice 6) + `voiceModeAgentId`/`isVoiceMode` + the `shouldAutoAllowVoicePermission` predicate (Slice 3) → `packages/server/src/server/voice/voice-session-controller.ts`. Carve voice types out of `dispatchVoiceAndControlMessage`, leaving infra (restart/shutdown/heartbeat/ping/abort) on the shell.
**Move:** voice handlers + ~25 voice fields + the TTS-debug hook (Slice 6) + `voiceModeAgentId`/`isVoiceMode` + the `shouldAutoAllowVoicePermission` predicate (Slice 3) → the existing`packages/server/src/server/session/voice/voice-session.ts` (see Progress above). Carve voice types out of `dispatchVoiceAndControlMessage`, leaving infra (restart/shutdown/heartbeat/ping/abort) on the shell.
@@ -9,17 +9,17 @@ A release has exactly two steps. The agent does the first, the user authorizes t
**Preparation** (local, reversible — agent does this):
- format, lint, typecheck all green
- ACP provider catalog drift checked with `npm run acp:version-drift:check`;
if stale package-runner pins are intentional, say so explicitly, otherwise run
`npm run acp:version-drift:update` and commit the updated catalog
- resolve the release source to one commit and confirm that commit's existing CI is green
- classify the diff from the previous stable to the release source as patch or minor, then show the
target version and rationale to the user
- draft the changelog, show it to the user, wait for review
- run the pre-release sanity check, surface findings to the user
- confirm CI is green
**Go-ahead** (user says "go ahead"):
- commit the approved changelog
- run the release
- commit the approved release inputs locally
- run the release, which publishes npm and pushes the prepared branch and tag
- create the release heartbeat immediately and babysit it to completion
Rules that apply to both steps:
@@ -27,29 +27,140 @@ Rules that apply to both steps:
- No code changes bundled into the changelog commit or the release commit. Code shims live in their own commit, reviewed on their own merits.
- A sanity-check finding is information, not a directive. The agent surfaces it; the user decides.
- Invoking a release skill is intent to start the flow, not blanket authorization to publish.
- If the user asks for a release preview, show the prospective changelog/release contents and answer questions, but do not commit, tag, publish, or run release commands until they explicitly authorize the release.
## Release source and CI
The default release source is `origin/main`. Fetch `origin`, then record the
resolved commit. The default release checkout is a clean local `main` whose
`HEAD` equals `origin/main`.
An explicit user instruction can select another ref, such as a hotfix commit or
tag. Resolve that ref once and apply every source, diff, and CI check to that
commit instead of `origin/main`.
Before making release-preparation commits, confirm the existing CI run for the
resolved commit is green. Pending CI is watched to completion. Release
preparation then stays local through the changelog, any explicitly requested ACP
catalog update, lockfile preparation, and the version commit. After approval,
commit the prepared inputs locally and run the release command. Its branch and
tag push is the one remote release batch and starts CI for the complete release
commit.
## Release branch discipline
While you finalize a release on `main`, use a temporary `next` branch for work intended for the following
release. This applies to both beta and stable releases.
- Create each new `next` from freshly fetched `origin/main`. Reuse it while active.
- "This goes to next" means create the PR against `next` or retarget an existing
PR, and keep that destination through delivery.
- Keep `next` current by merging `origin/main` into it as release fixes land.
Avoid rebasing this shared branch because agents and open PRs depend on its history.
- After the release ships, bring `next` up to date and open a `next` → `main` PR.
Pass CI and merge without squashing away the individual PR commits needed for
the changelog. Retarget remaining PRs based on `next` to `main` and delete the integrated
`next`. Create it fresh when needed again.
**Setup still needed:** CI, Docker, and Nix PR checks currently target only `main`,
and GitHub permits only squash merges. Enable checks and required-check protection
for `next`, CI on its pushes, and merge commits for the integration PR. Handle PR
base changes (`edited` events) so retargeting runs checks against the new base;
GitHub's default PR events do not cover this. Deployment triggers stay unchanged.
### Hotfix from a release tag
If `main` contains changes you do not want to release, branch from the affected
release tag and cherry-pick only the required fixes. Run CI on that branch, then
use the normal release flow with it as the explicit source, choosing a new patch
or beta version. Ensure the fixes and changelog also reach `main` and any active
`next`, preserving newer development and version changes there. This is a
short-lived hotfix branch, not another maintained release track.
## ACP catalog updates
ACP catalog work enters a release through an explicit user request:
- **Check ACP drift** — run `npm run acp:version-drift:check`. When drift exists,
run `npm run acp:version-drift:update`, verify the catalog, and include the
update in the local release-preparation commits.
- **Update ACP** — run `npm run acp:version-drift:update`, verify the catalog, and
include the update in the local release-preparation commits.
The release authorization covers the requested ACP commit. It ships in the same
release push as the changelog and version commit.
## Two paths
There are two supported ways to ship from `main`:
There are two supported release paths:
1. **Direct stable release**: you are ready to ship the current `main` commit to everyone immediately.
2. **Beta flow**: silent release candidates. Betas don't touch the changelog, don't move the website, and publish npm only on the explicit `beta` dist-tag.
1. **Direct stable release**: you are ready to ship the resolved release source to everyone immediately (default `origin/main`).
2. **Beta flow**: release candidates on the `beta` channel. Each beta folds into the series' single changelog entry, publishes npm only on the explicit `beta` dist-tag, and stays behind the Stable/Beta switch on `/download`.
## Standard release (patch)
Paseo has one linear release track even though npm dist-tags are independent
pointers. The npm invariant is:
Before running any stable patch release command:
- A beta release moves only `beta`; `latest` remains on the newest stable.
- A stable release moves both `latest` and `beta` to that stable version. This
keeps users who install `@getpaseo/cli@beta` on the newest Paseo release after
a beta is promoted or superseded by a direct stable release.
- Make sure the intended release commit is already committed to `main` and the working tree is clean.
## Release version decision
Every fresh release starts by classifying the full diff from the previous
stable to the resolved release source. The highest-impact change determines the
version:
- **Minor** — a user would experience the release as a significant upgrade. This
includes substantial new workflows, providers, forges, platforms, integrations,
or meaningful expansions of existing capabilities. Foundational internal work
also qualifies when it materially changes reliability, performance,
compatibility, deployment, or operation; diff size alone does not.
- **Patch** — fixes, polish, small enhancements, and reliability or performance
improvements within existing capabilities. Follow-up corrections to a minor
release are patches.
The release agent selects patch or minor during preparation and presents the
target version with the changelog for approval. Agents never select a major
version autonomously. A major release requires an explicit user instruction and
approval; Paseo remains on major version zero until that deliberate decision.
Version bumps are never used to retry a failed build. Retry the existing version
as described in **Fixing a failed release build**.
## Standard release (stable)
Before running any stable release command:
- Make sure the resolved release source passed CI, the approved release inputs are committed locally on the intended branch, and the working tree is clean.
- **Run `npm run format`, `npm run lint`, and `npm run typecheck` and commit any resulting changes BEFORE you start any `release:*` command.**`release:check` runs `npm install --workspaces --include-workspace-root` as part of `release:prepare`, which can mutate `package-lock.json` (e.g. churning `"dev": true` markers on optional deps). The next step, `version:all:*`, runs `npm version` which aborts when the working tree is dirty. If this happens mid-flight you have to commit the lockfile churn before retrying — and the pre-commit format hook will reject a lockfile-only commit because oxfmt internally skips `package-lock.json` while lefthook's glob still matches it. Avoid the whole mess by running format/lint/typecheck first, then `release:prepare` once on its own to absorb any lockfile churn into a normal commit, then start the release.
- Do not use `npm run release:patch` as a substitute for checking whether the current commit is actually ready.
- Do not use a release command as a substitute for checking whether the current commit is actually ready.
```bash
# Run exactly one, matching the approved decision:
npm run release:patch
npm run release:minor
```
This bumps the version across all workspaces, runs checks, publishes to npm, and pushes the branch + tag. The tag push triggers `Desktop Release`, `Android APK Release`, and `Release Notes Sync` on GitHub Actions. EAS picks up the same tag via the EAS GitHub app and starts the iOS + Android store builds in parallel (see "Mobile builds (EAS)" below) — there is no `release-mobile.yml` in this repo.
This bumps the version across all workspaces, runs checks, publishes to npm, and pushes the branch + tag. The tag push triggers `Desktop Release`, `Android APK Release`,`Docker`, and `Release Notes Sync` on GitHub Actions. The workflows create the GitHub Release as a draft while builds and release-note sync run. EAS picks up the same tag via the EAS GitHub app and starts the iOS + Android store builds in parallel (see "Mobile builds (EAS)" below) — there is no mobile-release workflow under `.github/workflows`.
**Releases are always patch.** "Release paseo", "release stable", "ship stable", and similar always mean a patch bump from the previous stable. Never bump minor or major to trigger a build, ever — minor and major bumps are reserved for genuinely larger product cuts and require an explicit user instruction with the word "minor" or "major". If you find yourself reaching for `release:minor` to retrigger a failed build, you are doing the wrong thing — push a retry tag instead (see "Fixing a failed release build" below).
After the stable release succeeds, move npm's `beta` pointer to the new stable
version for every published package. This changes dist-tags only; do not
Verify both npm tags now resolve to `PASEO_VERSION` before considering the
stable release complete.
The Docker workflow builds images from the checked-out source tree on pull requests and on `main` as non-publishing checks. Stable `vX.Y.Z` tag pushes publish `ghcr.io/getpaseo/paseo:X.Y.Z` and `ghcr.io/getpaseo/paseo:latest`; beta `vX.Y.Z-beta.N` tag pushes publish only `ghcr.io/getpaseo/paseo:X.Y.Z-beta.N` and never move `latest`.
The production relay is the Elixir service in [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay), with its own deployment process. Paseo releases and pushes to this repository do not deploy it. The Cloudflare relay code and workflow in this repository are legacy and are not used in production.
**Stable means stable.** If the user says "stable" or "ship stable", do not ask whether they want a beta first. They picked stable; treat it as a direct stable release. Only run the beta flow when the user explicitly says "beta".
@@ -58,15 +169,19 @@ This bumps the version across all workspaces, runs checks, publishes to npm, and
```bash
npm run typecheck # Verify the exact commit you intend to release
npm run release:check # Typecheck, build, dry-run pack
npm run version:all:patch # Bump version, create commit + tag
# Run exactly one approved version command:
npm run version:all:patch
npm run version:all:minor
npm run release:publish # Publish to npm
npm run release:push # Push HEAD + tag (triggers CI workflows)
# Then move npm's beta dist-tag to this stable version using the command above.
```
## Beta flow
```bash
npm run release:beta:patch # Bump to X.Y.Z-beta.1, publish npm beta, push commit + tag
npm run release:beta:patch # Start the next patch beta line
npm run release:beta:minor # Start the next minor beta line
# ... test desktop and APK prerelease assets from GitHub Releases ...
npm run release:beta:next # Optional: cut X.Y.Z-beta.2, beta.3, ...
npm run release:promote # Promote X.Y.Z-beta.N to stable X.Y.Z
@@ -74,11 +189,12 @@ npm run release:promote # Promote X.Y.Z-beta.N to stable X.Y.Z
- Beta tags are published GitHub prereleases like `v0.1.41-beta.1`
- Betas publish npm packages with `--tag beta`, so `npm install @getpaseo/cli@beta` opts in while plain `npm install @getpaseo/cli` stays on `latest`
- Betas publish desktop assets and APKs for testing, but they do not trigger the production web/mobile release flows
- Betas publish desktop assets and APKs for testing. They also build iOS, upload it to TestFlight, add it to the `Paseo Beta` external group, and submit it for Beta App Review. They do not submit mobile builds to the production stores.
- `release:promote` creates a fresh stable tag like `v0.1.41`; the final release never reuses the beta tag
- Desktop assets now come from the Electron package at `packages/desktop`
- Require the Linux artifact CI checks with both restricted and usable user namespaces to pass before publication; see [packaged desktop smoke](testing.md#packaged-desktop-smoke). Keep the installed-package and AppImage checks together.
- Beta releases use Electron's `beta` update channel. Users on the stable channel only receive stable releases; users on the beta channel receive beta releases and the final stable release when it is published.
- **Betas don't touch `CHANGELOG.md`.** Beta GitHub releases ship with empty notes — that's intentional. The changelog entry is written once, at promotion time, covering the full stable-to-stable diff. The release-notes sync script skips betas cleanly because no matching section exists.
- **A beta series has one changelog entry.** Each beta folds into it and renames its heading to the new `## X.Y.Z-beta.N`, which `Release Notes Sync` mirrors into that prerelease body. Promotion renames it to the stable heading. See the Changelog policy section.
Use the beta path when you need to:
@@ -95,14 +211,14 @@ The rollout is driven by a `rolloutHours` field stamped into the GitHub Release
Desktop release builds now publish in two phases:
- Platform build jobs upload the installers/packages (`.dmg`, `.zip`, `.exe`, `.AppImage`, etc.) to the GitHub release.
- The final job merges/stamps the manifests and uploads all `.yml` files only after they already contain the final `releaseDate` and `rolloutHours`.
- The GitHub Release stays a draft while platform build jobs upload the installers/packages (`.dmg`, `.zip`, `.exe`, `.AppImage`, etc.).
- The final job merges and stamps every channel manifest, uploads them with the final `releaseDate` and `rolloutHours`, then publishes the GitHub Release.
Updater clients only discover a release through those `.yml` manifests, so there is no silent 100% admission window before rollout metadata is present.
Drafts do not appear in GitHub's releases feed. Updater clients continue to see the previous complete release until all three manifests are available. If a desktop build or manifest upload fails, the new release stays a draft.
### Default behavior
`npm run release:patch` → tag push → 36h ramp. No extra action needed.
`npm run release:patch` or `npm run release:minor` → tag push → 36h ramp. No extra action needed.
The `rollout_hours` input on `desktop-release.yml` is **only read on `workflow_dispatch`** — tag-push runs always default to 36. To get any other rollout duration on a fresh release, use the post-publish flip below.
@@ -122,7 +238,7 @@ gh workflow run desktop-rollout.yml \
**Why this is gap-free:** `desktop-release.yml`'s `finalize-rollout` job and `desktop-rollout.yml` share the concurrency group `desktop-rollout-<tag>`. Dispatching `desktop-rollout.yml` while the tag-push pipeline is still running queues it safely behind `finalize-rollout`. The first public manifests already carry `rolloutHours=36`, then `desktop-rollout.yml` flips them to `rolloutHours=0` shortly afterward. The renderer polls every 30 minutes, so active stable users pick up the new manifest on their next check.
Run the dispatch right after `release:patch` returns. Don't wait for the tag-push CI to finish.
Run the dispatch right after `release:patch` or `release:minor` returns. Don't wait for the tag-push CI to finish.
### Adjusting an already-published release
@@ -160,17 +276,26 @@ gh workflow run desktop-release.yml \
-f rollout_hours=6
```
This does **not** apply to fresh releases cut via `npm run release:patch` — that path always tag-pushes and stamps 36. For a fresh release with a custom ramp, cut normally and then dispatch `desktop-rollout.yml` (same pattern as the instant-admit flow above, with your chosen `rollout_hours`).
This does **not** apply to fresh releases cut via `npm run release:patch` or `npm run release:minor` — those paths always tag-push and stamp 36. For a fresh release with a custom ramp, cut normally and then dispatch `desktop-rollout.yml` (same pattern as the instant-admit flow above, with your chosen `rollout_hours`).
### Releasing during an active rollout
If you ship N+1 while N is still ramping, N+1 starts a fresh rollout from its own publish timestamp. N's rollout effectively ends — the newer manifest supersedes it.
If you ship N+1 while N is still ramping, N+1 starts a fresh rollout from its own publish timestamp. N's rollout effectively ends — the newer manifest supersedes it. Rollout-aware clients revalidate the manifest for up to five seconds before installing a downloaded update on quit. If N+1 has replaced N but the client is not admitted to N+1 yet, it skips the downloaded N and waits rather than installing two updates in succession. If revalidation times out, the app exits without installing the cached update.
If N+1 is a hotfix for a bug in N, dispatch `desktop-rollout.yml -f tag=v0.1.<N+1> -f rollout_hours=0` after N+1 publishes so the users who already got N reach the fix fast.
### macOS system floor
The desktop app requires macOS 13 or newer. Keep both release guards when the floor changes:
- `packages/desktop/electron-builder.yml` writes the macOS version to `LSMinimumSystemVersion` for new installs.
- `scripts/merge-mac-manifest.mjs` writes the matching Darwin kernel version to `minimumSystemVersion` in the update manifest. Existing clients check this before downloading an update.
macOS 13 maps to Darwin 22. The two values use different version domains; do not copy the macOS version into the update manifest.
### Limitations
- **No pause / kill switch.**Once a stable user is admitted, they will install the update on next quit (`autoInstallOnAppQuit = true`). To stop new admissions, ship a superseding release. To "recall" already-admitted users, ship a hotfix `+1` patch.
- **No pause / kill switch.**To stop new admissions, ship a superseding release. Clients revalidate on quit and will not install the superseded download, but a client that already completed installation cannot be recalled; ship a hotfix `+1` patch.
- **No rollback.**`allowDowngrade = false`. Bad release = ship a hotfix.
- **Bootstrap caveat.** Clients running a build older than the rollout feature ignore `rolloutHours` and admit immediately. Rollout protection only applies to clients running the rollout-aware version or later.
- **Up to ~30 min automatic admission latency.** Renderer polls every 30 minutes, so a stable user may take up to that long to be evaluated against the rollout window. Clicking **Check** is manual and bypasses rollout admission.
@@ -183,7 +308,11 @@ iOS and Android store builds are not in `.github/workflows`. They are triggered
- **iOS (TestFlight + App Store)** — EAS builds with profile `production`, uploads to TestFlight, and a Fastlane lane submits the build for App Store review.
- **Android APK (GitHub Release asset)** — separate, via `.github/workflows/android-apk-release.yml`. This is the only Android-related workflow that lives in this repo.
There is no `release-mobile.yml` in this repo. Earlier versions of these docs referenced one — that workflow was removed and the EAS GitHub app handles tag triggering directly.
EAS uses the local app version source. `packages/app/app.config.js` derives the native version from the package version. Android `versionCode` is `major * 1_000_000 + minor * 1_000 + patch`. iOS reserves 1,000 build slots per app version: beta `N` uses slot `N`, and stable uses slot `999`. For example, `0.2.6-beta.2` appears in App Store Connect as version `0.2.6` build `2006002`; stable uses build `2006999`. Rebuilding the same tag produces the same native build number; if a store has already accepted a binary and you need a different binary, cut the next beta or patch instead of relying on EAS remote auto-increment.
Beta tags run `Release iOS Beta`. The workflow uploads the build to TestFlight, distributes it to the persistent `Paseo Beta` external group, and submits it for Beta App Review. Testers and the group are managed once in App Store Connect; releases require no dashboard action.
There is no mobile-release workflow under `.github/workflows`. The EAS GitHub app reads the workflows under `packages/app/.eas/workflows` and handles tag triggering directly.
### Watching mobile builds from the terminal
@@ -232,11 +361,30 @@ Do not treat `build_ios: SUCCESS` or `submit_ios: SUCCESS` as a completed iOS re
To confirm the submission landed, inspect the EAS workflow with `npx eas workflow:view <workflow-run-id> --json`. App Store Connect (review state for the matching version/build) and the Play Console track are the final ground truth.
### Babysitting mobile after a release
## Release completion and heartbeat
The user rarely opens the Expo dashboard. A failed EAS build or submit/review job can sit silently until users complain about a stale version. After every stable release, set up a long-delay babysit that re-checks GitHub Actions, EAS builds, and the EAS `Release Mobile` workflow for the release tag. If any build is `ERRORED`/`CANCELED`, any workflow is `FAILURE`, or any required submit/review job fails, surface it immediately. If all builds are `FINISHED` and all required submit/review jobs are `SUCCESS`, confirm and stop.
A release is **in progress** after npm publication and tag push. Report it as
**shipped** only after every applicable build, publication, asset, manifest, and
store submission passes the completion checklist.
**Use `create_heartbeat`, never `create_schedule`, for release babysitting.** Babysitting fires back into the current conversation as a wake-up prompt. `create_schedule` starts a fresh agent the user has to find and read; `create_heartbeat` surfaces the build status inline in the conversation that owns the release, where it is impossible to miss. If you find yourself reaching for `create_schedule` for a release babysit, you are about to ship a status report into a void.
Immediately after every beta, stable, or promotion tag push, create a heartbeat
that resumes the release in the current conversation. Create it automatically
with `create_heartbeat`. The heartbeat owns the release until it either reaches
the completion checklist or finds a failure that needs new user authority.
Each heartbeat checks the release tag commit, all GitHub Actions runs for the
release branch and tag, npm dist-tags, the GitHub Release body and assets,
desktop updater manifests, the published Docker image, and the applicable EAS
workflow. Inspect the GitHub Release itself and confirm that the macOS, Linux,
Windows, and Android APK assets are present along with the channel manifests
(`latest-mac.yml`, `latest-linux.yml`, and `latest.yml` for stable;
`beta-mac.yml`, `beta-linux.yml`, and `beta.yml` for beta).
For stable releases, also confirm every required mobile build, upload, store
submission, and review-submission job for the release commit. For betas, confirm
the beta EAS workflow completed its TestFlight distribution and Beta App Review
path. Delete the heartbeat only after every applicable checklist item passes,
then report the release as shipped.
Pattern:
@@ -244,13 +392,16 @@ Pattern:
// mcp__paseo__create_heartbeat arguments
{
"name": "vX.Y.Z release babysit heartbeat",
"cron": "*/15****",
"maxRuns": 8, // covers ~2h of build + store-submission window
"prompt": "Heartbeat: check vX.Y.Z release. Run gh run list, eas build:list, eas workflow:runs, and eas workflow:view for the matching Release Mobile run. Report concisely. The release is not done until desktop/APK workflows are green, EAS builds are FINISHED, Android submit_android is SUCCESS, and iOS submit_ios + submit_ios_for_review are SUCCESS. Flag any ERRORED/FAILED/CANCELED/FAILURE loudly.",
"cron": "*/10****",
"timezone": "UTC",
"maxRuns": 120,
"expiresIn": "24h",
"prompt": "Resume the vX.Y.Z release babysit for commit <sha>. Check npm tags; every GitHub Actions run for the release branch and tag; the published GitHub Release body, expected desktop/APK assets, and channel manifests; the Docker image; and the matching EAS workflow. Completion requires every applicable checklist item. For stable, require build_ios, submit_ios, submit_ios_for_review, build_android, and submit_android to succeed. For beta, require the beta TestFlight distribution and Beta App Review path. If work is pending, wait for the next heartbeat. If a failure can be retried safely for the same version, follow the failed-release procedure; otherwise report the blocker. When every applicable completion-checklist item passes, delete THIS heartbeat, report shipped, and stop.",
}
```
Tight cadence on purpose. The first run fires immediately, giving a near-real-time status check before the conversation closes. Subsequent runs at 15-minute intervals catch transitions quickly: a failed EAS build or failed App Store review submission at +20m should not wait until +50m to surface. Keep the prompt short — the heartbeat is a status probe, not a research task — and have it bail out as soon as every platform is actually on its store path so the remaining runs do not generate noise.
Run an immediate status check after creating the heartbeat. The heartbeat handles
later transitions and stops itself when the release is complete.
## Release notes on GitHub
@@ -258,10 +409,13 @@ The GitHub Release body is populated automatically by the `Release Notes Sync` w
## Website behavior
- The website download page points to GitHub's latest published **stable** release.
- Published beta prereleases are public on GitHub Releases, but they do **not** become the website download target.
- The website only moves when you publish the final stable release tag like `v0.1.41`.
- The website itself is deployed by `Deploy Website` (Cloudflare Workers), which redeploys on `release: published` for non-prerelease releases and on pushes to `main` that touch `CHANGELOG.md` or `packages/website/**`.
- The website download page defaults to GitHub's latest published **stable** release.
- A published beta prerelease is offered behind the Stable/Beta switch on `/download` (`?channel=beta`), never as the default. The switch only appears while the newest prerelease leads stable on its core version, so promoting `X.Y.Z-beta.N` to `X.Y.Z` retires the beta channel from the page until the next beta line opens.
- Homebrew, the Play Store, the App Store, and `app.paseo.sh` have no beta. The Beta view drops those rows, and the whole Web section, rather than showing an inert "stable only" placeholder. When a surface gains a beta path — say a public TestFlight link — add its row back in `packages/website/src/routes/download.tsx`.
- The default download target only moves when you publish the final stable release tag like `v0.1.41`.
- The public `/changelog` page renders `CHANGELOG.md` as-is, so the in-flight `-beta.N` entry shows there once it lands on `main` — that's intended, it's where beta users check what's coming. Only the **default download target** stays pinned to the latest stable; the download links read GitHub's releases API, not the changelog, so a `-beta.N` heading on top never affects them.
- The download page's "What's new" link deep-links the **minor group** anchor (`/changelog#release-0.3`), not the exact entry: the beta entry is renamed on each beta and on promotion, so the minor group remains the durable target. A version with no entry in the bundled changelog — a tag whose changelog commit hasn't redeployed the site yet — links the plain `/changelog` instead of a dead anchor.
- The website itself is deployed by `Deploy Website` (Cloudflare Workers), which redeploys on the `release: published` event emitted when a stable draft is published and on pushes to `main` that touch `CHANGELOG.md` or `packages/website/**`. Its job condition excludes beta prereleases.
## Fixing a failed release build
@@ -269,9 +423,37 @@ The GitHub Release body is populated automatically by the `Release Notes Sync` w
**Do not rely on `workflow_dispatch` for tagged code fixes.** The `workflow_dispatch` trigger runs the workflow file from the default branch but checks out the code at the tag ref (`ref: ${{ inputs.tag }}`). That means fixes committed to `main` won't change the tagged source tree being built. `workflow_dispatch` only helps when the fix lives in the workflow file itself.
To retry a failed workflow, **always push a retry tag** on the commit you want to build. Reusing the same tag name is expected: move it with `git tag -f ...` and push it with `--force` so the workflow rebuilds the commit you actually want.
For Docker-only retries, **do not push or force-push a `v*` release tag**.
`v*` tag pushes rebuild desktop assets, the Android APK, Docker, release notes,
and EAS mobile release builds. Use the Docker workflow dispatch instead:
Prefer a tag push over `workflow_dispatch` whenever you are rebuilding release code or release assets.
```bash
gh workflow run docker.yml \
--ref main \
-f paseo_version=X.Y.Z-beta.N \
-f publish=true
```
This replaces `ghcr.io/getpaseo/paseo:X.Y.Z-beta.N` in place without touching
desktop, APK, or EAS release builders. The Docker exception is safe because the
dispatch runs from `--ref main` and uses the explicit `paseo_version`; it does
not check out or move the `v*` release tag.
To retry a failed non-Docker release workflow, push a retry tag on the commit
you want to build. Reusing the same tag name is expected: move it with
`git tag -f ...` and push it with `--force` so the workflow rebuilds the commit
you actually want.
A failed desktop build leaves the GitHub Release as a draft. `finalize-rollout`
uploads manifests from successful platforms before it fails. A later
single-platform retry reuses those manifests, stamps the complete set with one
release date, and publishes the draft. Use `desktop-vX.Y.Z` when more than one
platform failed. A `workflow_dispatch` rebuild with publishing enabled follows
the same path against the existing draft.
Prefer a tag push over `workflow_dispatch` when rebuilding desktop or APK
release assets. Prefer Docker workflow dispatch when rebuilding only the Docker
image.
The retry tag patterns below still work and remain the supported way to rebuild specific release targets:
@@ -298,9 +480,26 @@ This ensures the checkout ref matches the actual code on `main` with the fix inc
- `desktop-macos-vX.Y.Z`, `desktop-linux-vX.Y.Z`, and `desktop-windows-vX.Y.Z` rebuild only that desktop platform
- `android-vX.Y.Z` rebuilds the Android APK release only
If you decide to publish a release without working desktop builds, inspect its
- The npm `version` lifecycle regenerates F-Droid changelog files from `CHANGELOG.md` for stable releases only (`npm run fdroid:changelogs`) and stages them, so the release tag carries them. Betas are a no-op. A stable run **aborts the release** if `CHANGELOG.md` has no entry for the version being cut — commit the changelog entry first. See [docs/android.md](android.md) for why these files are generated per ABI.
- `release:prepare` refreshes workspace `node_modules` links to prevent stale types
- `npm run dev:desktop` and `npm run build:desktop` target the Electron desktop package in `packages/desktop`
- If `release:publish` partially fails, re-run it — npm skips already-published versions
@@ -313,44 +512,61 @@ Release notes depend on the changelog heading format. The heading **must** be st
```
## X.Y.Z - YYYY-MM-DD
## X.Y.Z-beta.N - YYYY-MM-DD
```
No prefix (`v`), no extra text. The parser matches the first`## X.Y.Z` line to extract the version. A malformed heading will break download links on the homepage.
No prefix (`v`), no extra text. `Release Notes Sync` matches the `## X.Y.Z` (or`## X.Y.Z-beta.N`) line for the pushed tag to extract the version. A malformed heading breaks the release-notes sync for that tag.
`CHANGELOG.md` on `main` is also what the app's **What's new** sheet fetches and renders, so the file is a shipped product surface, not just a release input. `##` starts a release and `###` starts a section; the app reads section titles from the document, so renaming or adding one needs no app change. Everything under a section is rendered as Markdown: prose, lists, links, inline code, fenced code, block quotes, tables, and images. Raw HTML does not render — the shared Markdown parser runs with `html: false`, so a `<video>`, `<iframe>` or `<embed>` tag reaches the reader as visible markup. Keep media out of the changelog, or link to it. A GitHub callout renders as a block quote with its `[!NOTE]` marker still in the text. A release entry is what a user reads on a phone the moment they are offered the update — write it for them.
## Changelog policy
- `CHANGELOG.md`only lists stable releases. Betas are silent.
- The changelog entry is authored once, at stable promotion time, with the date set to the promotion day.
- It covers the full diff from the previous stable tag, regardless of how many betas were cut in between.
- `CHANGELOG.md`holds stable releases and at most one entry for the current beta series.
- The first beta of a version inserts a top entry like `## 0.1.60-beta.1 - YYYY-MM-DD`.
- Each subsequent beta rewrites that entry in place: the heading becomes the new beta number and date, and the body describes the full change from the previous stable release to this beta. Never add a second beta entry.
- The entry is a list of changes, not of PRs. When a later beta reverts, reworks, or follows up on something an earlier beta added, rewrite the bullet to the current behavior or delete it. A stable user never saw the earlier beta behavior, so fixes to it are not "Fixed" bullets.
- Anything that already shipped to stable through a patch release on an older line stays out of the beta entry.
- Stable promotion renames the entry to `## 0.1.60 - YYYY-MM-DD` after a final review.
Older prerelease bodies on GitHub keep the notes they were synced with; `Release Notes Sync` only writes the release whose tag matches the top entry.
## Changelog ownership
- **The agent running the stable release writes the changelog entry.** Do not hand the changelog to another model or agent. The release agent has the release context and owns the final wording.
- Draft the entry from the stable-to-stable diff, review it against the changelog policy below, show it to the user, and wait for approval before committing it.
- **The agent running the release writes the changelog entry — beta or stable.** The release context and final wording stay with that agent.
- **Commit history is only an index of the changes. Never draft the changelog from commit subjects or diffs alone.** For every PR in the release range, read the full PR description and every issue it links to before deciding what changed, why users care, or how changes should be grouped. Use the implementation only to verify the resulting understanding.
- Every entry, beta or stable, is drafted from the previous stable release to the release source. For a later beta, read the PRs since the previous beta tag, then rework the existing entry so each bullet still describes current behavior. Review the result against the changelog policy below, show it to the user, and wait for approval before committing it.
## Changelog voice
## Changelog wording
The changelog is shown on the Paseo homepage. Write it for **end users**, not developers.
The changelog is shown on the Paseo homepage. Each bullet is a compact factual record of
product behavior that changed.
- **Frame everything from the user's perspective.** Describe what changed in the app, not what changed in the code. Users care that "workspaces load instantly" — not that a component no longer remounts.
- **Never mention component names, internal modules, or implementation details.** No `WorkingIndicator`, no `accumulatedUsage`, no `reconcileAndEmitWorkspaceUpdates`. Also no "virtualized lists", no "remount", no "memoization", no "debounced", no "fuzzy ranking", no "controlled input", no "uncontrolled input" — these are implementation words masquerading as user-facing copy.
- **Concrete WRONG → RIGHT examples** (real mistakes from past releases):
- **Name the exact change.** Prefer `Added <capability>`, `Removed <behavior>`,
`Changed <behavior>`, or`Fixed <failure> when <condition>`.
- **Keep the scope exact.** A conditional bug is not a general reliability problem. Do not
broaden one failure into claims that Paseo is now faster, smoother, responsive, or reliable.
| Paseo stays responsive with many idle Git workspaces | Removed periodic Git polling for idle workspaces |
| Incompatible saved app data no longer crashes after upgrades | Fixed crash when persisted cache was incompatible |
| Splitting layouts no longer remounts the active agent | Fixed scroll position resetting when splitting a pane |
| Mobile model selector is faster and more straightforward | Added search to the mobile model selector |
Test: would a non-developer reader recognise what changed when using the app? If they'd need an engineer to translate ("what's a remount?"), the bullet is still implementation-facing — rewrite it as the symptom the user experiences.
Test each bullet against the source PR and issue: can a reviewer point to the exact behavior
that changed, the failure that was fixed, or the capability that was added? If the bullet only
claims a general improvement, rewrite it with the concrete change.
- **Collapse internal iterations.** If a feature was added and then fixed within the same release, just list the feature as working. Users never saw the broken version.
- **Only list changes relative to the previous stable release.** The diff is `v(previous)..HEAD`. If something was introduced and fixed between those two tags, it never shipped — don't mention the fix.
- **Common trap:** when drafting from `git log`, every commit looks like a separate bullet — including the "fix X" commits that landed on top of a brand-new feature in the same release window. Before listing a Fixed entry, check whether the thing being fixed was itself added in this same release. If so, drop the fix and fold it into the feature bullet.
- **Example:** if the release adds an in-app browser and also contains a commit "fix: browser pane keyboard handling no longer steals shortcuts", do **not** list the keyboard fix under Fixed. The browser is shipping for the first time, so users will only ever see the working version. The Added entry covers it.
- **Use the entry's release scope.** Include changes within the matching range in **Changelog scope**.
- **Collapse internal iterations within that scope.** Present a feature added and fixed in one range as working.
- **Cut low-signal entries.** "Toolbar buttons have consistent sizing" is too granular. Combine small polish items or drop them.
## Changelog conciseness
@@ -362,8 +578,7 @@ Every bullet must be scannable at a glance. The changelog is not release documen
- **One line per bullet.** If a bullet wraps to three lines in a narrow column, it's too long.
- **Split bullets that pack multiple distinct changes.** If a bullet uses "and", "plus", a comma list, or an em-dash to chain several independent improvements, break them into separate bullets — even when they share a theme or author. One bullet = one user-facing change.
- **Trim qualifying clauses.** Drop "with a hint shown when…", "matching the CLI's behaviour", "across common install shapes". If the detail doesn't change whether a user cares, cut it.
- **Lead with what the user can do, not the mechanism.** The reader cares about the capability, not how it works under the hood. Do not explain LAN vs WAN, TLS handshakes, IPC, the daemon-relay topology, or any internal concept the user has not asked about. "Self-hosted relays can use a different TLS setting for the public endpoint" — not "Self-hosted relays support a separate TLS setting for the public endpoint, so the daemon can reach the relay over the LAN while the phone reaches it over the public secure address." If a feature genuinely needs background to be understood, it belongs in product docs, with a one-line teaser in the changelog.
- **Lead with the outcome.** "Windows: agents launch reliably from npm `.cmd` shims…" is better than "Windows: agents launch reliably across common install shapes. Claude, Codex, and OpenCode now start correctly…".
- **Stop after identifying the change.** Do not explain LAN/WAN topology, TLS handshakes, IPC, or other architecture in a changelog bullet. Put necessary background in product docs.
- **Attribution follows the split.** When you split a dense bullet, move each PR/author to the bullet it belongs to. Never duplicate the same PR across multiple bullets.
## Changelog attribution
@@ -393,7 +608,7 @@ Rules:
This returns every distinct GitHub login that authored or co-authored a commit in the PR. Use those logins for attribution. Fall back to `gh pr view N --json author` only if the commits command returns nothing (which should not happen for merged PRs).
When listing PR numbers, `git log --format='%H %s' v<previous>..HEAD | grep -E '\(#[0-9]+\)$'` pulls the PR number out of squash commit subjects.
When listing PR numbers, `git log --format='%H %s' v<previous>..<release-source-sha> | grep -E '\(#[0-9]+\)$'` pulls the PR number out of squash commit subjects.
## Changelog ordering
@@ -407,41 +622,66 @@ Entries within each section (Added, Improved, Fixed) are ordered by user impact:
Before cutting a **stable** release, the release agent reviews the diff as a last line of defence against shipping bugs. Skip this for betas — the beta itself is the smoke test, and gating each beta on a code review defeats the point of using betas as fast release candidates.
Review the diff between the latest release tag and `HEAD`. Focus on:
Review the diff between the latest release tag and the resolved release source. Focus on:
1. **Breaking changes** — especially in the WebSocket protocol, agent lifecycle, and any server↔client contract.
2. **Backward compatibility** — the important direction is old app clients talking to newly updated daemons. Users update desktop and daemon first, then keep running the old app for a while. Flag anything that breaks old clients against new daemons or requires both sides to update in lockstep.
3. **Regressions** — anything that looks like it could break existing functionality.
Use `git diff <latest-release-tag>..HEAD` as the review input. This is a deep sanity check, not a full code review. If anything looks risky, investigate before proceeding and surface the finding to the user.
Use `git diff <latest-release-tag>..<release-source-sha>` as the review input. This is a deep sanity check, not a full code review. If anything looks risky, investigate before proceeding and surface the finding to the user.
## Changelog scope
The changelog covers **stable-to-stable**. Betas are not represented. When you promote, draft the entry from the diff between the previous stable tag and `HEAD`, ignoring beta tag boundaries — they're just checkpoints along the way.
Changelog scope follows the release being described:
Every entry covers `previous stable release → release source`: first beta, later beta, direct stable release, and promotion alike. The beta entry always reads as the stable entry would if the beta were promoted today.
## Completion checklist
### Beta release
- [ ] Working tree is clean and the intended commit is on `main`
- [ ] `npm run release:beta:patch` (or `:next`) completes successfully
- [ ] The resolved release source is the intended commit (default `origin/main`) and its existing CI is green
- [ ] Every PR in the release range has been opened, and its full description and every linked issue have been read before drafting the changelog
- [ ] Fold this beta into the series' single `CHANGELOG.md` entry (heading `## X.Y.Z-beta.N - YYYY-MM-DD`, previous stable → release source), review it against the changelog policy, get approval, and commit it before cutting the release
- [ ] The diff from the previous stable to the resolved release source is classified as patch or minor, with the target version and rationale approved
- [ ] Release preparation stayed local until the approved release command pushed the complete branch and tag
- [ ] `npm run release:beta:patch`, `npm run release:beta:minor`, or `npm run release:beta:next` completes successfully
- [ ] Every GitHub Actions run for the complete release commit and tag is green
- [ ] npm shows the version under the `beta` dist-tag, not `latest`
- [ ] The GitHub prerelease was published only after the three beta manifests were uploaded, and it has the changelog body and every expected macOS, Linux, Windows, and Android APK asset
- [ ] GitHub `Desktop Release` workflow for the `v*-beta.N` tag is green
- [ ] The GitHub prerelease contains `beta-mac.yml`, `beta-linux.yml`, and `beta.yml`
- [ ] GitHub `Android APK Release` workflow for the same tag is green
- [ ] GitHub `Docker` workflow is green and the versioned beta image is published without moving `latest`
- [ ] GitHub `Release Notes Sync` mirrored the beta entry into the prerelease body
- [ ] EAS `Release iOS Beta` completed its build, TestFlight distribution, external beta group, and Beta App Review path
- [ ] The release heartbeat was created after the tag push and deleted only after every item above passed
### Stable release (or promotion)
- [ ] Run the pre-release sanity check (see above) and address any findings
- [ ] Ensure the intended release commit is already committed and the git worktree is clean before running any `release:*` patch/promote command
- [ ] Ensure local `npm run typecheck` passes on that exact commit before running any `release:*` patch/promote command
- [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors)
- [ ] The diff from the previous stable to the resolved release source is classified as patch or minor, with the target version and rationale approved
- [ ] The resolved release source is the intended commit (default `origin/main`) and its existing CI is green
- [ ] Every PR in the release range has been opened, and its full description and every linked issue have been read before drafting the changelog
- [ ] Ensure the approved release inputs are committed locally and the git worktree is clean before running any release command
- [ ] Ensure local `npm run typecheck` passes on that exact commit before running any release command
- [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors). Promotion renames the series' `## X.Y.Z-beta.N` entry to `## X.Y.Z - YYYY-MM-DD` and re-checks it covers the full release
- [ ] Verify the changelog heading follows strict `## X.Y.Z - YYYY-MM-DD` format
- [ ] `npm run release:patch` or `npm run release:promote` completes successfully
- [ ] Release preparation stayed local until the approved release command pushed the complete branch and tag
- [ ] `npm run release:patch`, `npm run release:minor`, or `npm run release:promote` completes successfully
- [ ] Every GitHub Actions run for the complete release commit and tag is green
- [ ] Move npm's `beta` dist-tag to the new stable version for every published package and verify both `latest` and `beta` resolve to it
- [ ] The GitHub Release was published only after the three stable manifests were uploaded, and it has the changelog body and every expected macOS, Linux, Windows, and Android APK asset
- [ ] GitHub `Desktop Release` workflow for the `v*` tag is green
- [ ] The GitHub Release contains `latest-mac.yml`, `latest-linux.yml`, and `latest.yml`
- [ ] `latest-mac.yml` contains the current `minimumSystemVersion` guard
- [ ] GitHub `Android APK Release` workflow for the same tag is green
- [ ] GitHub `Docker` workflow is green and both the versioned and `latest` images are published
- [ ] GitHub `Release Notes Sync` is green and the release body matches the stable changelog entry
- [ ] EAS `Release Mobile` workflow for the same tag is green
- [ ] EAS iOS `build_ios` completes for the same tag
- [ ] EAS iOS `submit_ios` succeeds, uploading the build to App Store Connect/TestFlight
- [ ] EAS iOS `submit_ios_for_review` succeeds, putting the build into App Store review
- [ ] EAS Android `build_android` completes for the same tag
- [ ] EAS Android `submit_android` succeeds, putting the build on its Play Store track
- [ ] The release heartbeat was created after the tag push and deleted only after every item above passed
New WebSocket session RPCs use dotted names with the direction as the final segment:
```ts
checkout.github.set_auto_merge.request;
checkout.github.set_auto_merge.response;
checkout.forge.set_auto_merge.request;
checkout.forge.set_auto_merge.response;
```
The namespace reads left to right:
- Domain: `checkout`
- Provider or subsystem: `github`
- Namespace segment: `forge`
- Operation: `set_auto_merge`; this segment is a verb, not a noun. If you would name an RPC `noun.request`, name it `get_noun.request` instead.
- Direction: `request` or `response`
@@ -21,11 +21,17 @@ Use dots, not slashes. Dots are protocol namespaces; slashes imply paths or tran
For ordinary correlated RPCs, a `.request` has a matching `.response` with the same prefix. The daemon client may derive the response type mechanically:
```ts
checkout.github.set_auto_merge.request;
// -> checkout.github.set_auto_merge.response
checkout.forge.set_auto_merge.request;
// -> checkout.forge.set_auto_merge.response
```
Most new RPCs should follow this shape. If a request does not have a one-to-one response, call that out in the code near the schema.
A finite streaming request can emit `.update` messages before its terminal `.response`.
`usage.list_reports.request` uses this shape: each `.update` carries one settled report, and
`.response` carries the request-level error or `null`. All three share `requestId`; no updates
follow the response. An empty successful stream is an empty list. Register updates as replies
of the originating request so source-owned delivery routes them to the requesting connection.
Most new RPCs should follow these shapes. If a request does not have a one-to-one terminal response, call that out in the code near the schema.
## Message Shape
@@ -33,7 +39,7 @@ Requests keep their parameters at the top level:
```ts
{
type: "checkout.github.set_auto_merge.request",
type: "checkout.forge.set_auto_merge.request",
cwd: "/repo",
enabled: true,
mergeMethod: "squash",
@@ -45,7 +51,7 @@ Responses put correlated result data under `payload`:
```ts
{
type: "checkout.github.set_auto_merge.response",
type: "checkout.forge.set_auto_merge.response",
payload: {
cwd: "/repo",
enabled: true,
@@ -58,14 +64,16 @@ Responses put correlated result data under `payload`:
Keep `requestId` in both request and response payloads. It is the correlation key.
## Provider Namespacing
## Forge Namespacing
Provider-specific behavior belongs under the provider segment:
Forge-neutral behavior currently uses `checkout.forge.*` for checkout-scoped operations and `forge.search.*` for forge search; forge-specific names belong here only after schema and session handlers exist:
- `checkout.github.*` for GitHub-specific checkout operations
- `checkout.gitlab.*` for future GitLab-specific checkout operations
- `checkout.forge.*` for operations whose request/response shape is genuinely
forge-neutral and whose implementation dispatches through the forge resolver.
- `checkout.github.*` for existing GitHub-specific compatibility RPCs while
callers migrate to the neutral `checkout.forge.*` shape
Do not put GitHub-specific enums or semantics into generic checkout RPC names. A generic RPC should only exist when the behavior is genuinely provider-neutral.
Do not put GitHub-specific enums or semantics into `checkout.forge.*` RPC names. A generic forge RPC should only exist when the behavior is genuinely forge-neutral.
@@ -4,7 +4,7 @@ Paseo proxies HTTP traffic to services running inside your workspaces. Localhost
## How it works
When a `paseo.json` script of `"type": "service"` starts, Paseo assigns it a local port and registers a route in the service proxy. Incoming requests whose `Host` header matches the script's generated hostname are forwarded to that port.
When a `paseo.json` script of `"type": "service"` starts, Paseo assigns it a local port and registers a route in the service proxy. Incoming requests whose `Host` header matches the script's generated hostname are forwarded to that port. HTTP requests, WebSocket upgrades, and service health checks select the reachable IPv4 or IPv6 loopback address. Services can bind to `127.0.0.1` or `::1` without changing their proxy URL.
The generated hostname is built from the script name, branch, and project:
Local and public routes use one combined leftmost label (`script--branch--project`). This keeps the hostname compatible with normal single-level wildcard DNS and TLS. If the combined label would exceed DNS's 63-character label limit, Paseo truncates it with a deterministic hash suffix to avoid collisions.
## Managing workspace scripts
Configured `paseo.json` scripts can be managed without addressing their backing terminal directly:
```bash
paseo script ls [--cwd <path> | --workspace <workspace-id>]
The commands return the same script metadata shown by the workspace: lifecycle, service port, proxy URLs, health, exit code, and supervised terminal ID. `stop` terminates the managed terminal rather than only removing the proxy route, so normal script lifecycle cleanup remains authoritative. MCP exposes matching `list_workspace_scripts`, `start_workspace_script`, and `stop_workspace_script` tools; those require an explicit workspace ID.
## Configuration
Add a `serviceProxy` block under `daemon` in `~/.paseo/config.json`:
@@ -67,6 +79,19 @@ For generated URLs to be reachable, you need wildcard DNS pointing to the machin
Public service URLs expose the workspace service itself. Daemon password authentication protects daemon APIs; it does not protect proxied dev services.
If the same reverse proxy serves the daemon web UI over HTTPS, it must also set `X-Forwarded-Proto` so the web UI can auto-connect with `wss://`. The daemon trusts forwarded headers from loopback proxies by default. If your proxy reaches the daemon from another address, configure the proxy ranges explicitly:
```json
{
"version": 1,
"daemon": {
"trustedProxies": ["loopback", "172.16.0.0/12"]
}
}
```
`PASEO_TRUSTED_PROXIES` accepts the same comma-separated values, for example `loopback,172.16.0.0/12`. Use `true` only when the final trusted proxy overwrites client-supplied `X-Forwarded-*` headers.
Nginx example:
```nginx
@@ -77,10 +102,34 @@ server {
location / {
proxy_pass http://10.1.1.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
Nginx's `$host` drops the port. If you terminate on a non-default port, use `$http_host` instead so the port survives — that is what "forwards the `Host` header unchanged" means here.
## Forwarded headers
Paseo sets these when it forwards a request to a workspace service:
| `X-Forwarded-Host` | The `Host` header verbatim, including the port when the client used one |
| `X-Forwarded-Proto` | The request scheme (`http` on the WebSocket upgrade path) |
| `X-Forwarded-For` | The immediate peer address. Replaces any existing chain, so behind your own reverse proxy this is the proxy's address, not the client's |
| `X-Forwarded-Port` | The port from the `Host` header when it has one, otherwise whatever your proxy already set |
`X-Forwarded-Port` follows the same trust rule as `X-Forwarded-Host`: the authority Paseo observed wins. When the `Host` header carries a port, that port is reported and replaces any inbound `X-Forwarded-Port`, so a client cannot forge one. When `Host` carries no port there is nothing to observe, so a value your reverse proxy set survives untouched — that is the case where nginx's `$host` drops the port and `X-Forwarded-Port` is the only source. Paseo never derives the port from the scheme. Any other `X-Forwarded-*` header your proxy sends is passed through untouched.
Services that build absolute URLs should prefer `Host` or `X-Forwarded-Host`.
### The forwarded authority is not authenticated
Route lookup normalizes the port away before matching a service hostname, so a client can address the daemon with any port in `Host` and still reach the service. That port is what lands in `X-Forwarded-Host` and `X-Forwarded-Port`. Paseo also does not check whether an inbound `X-Forwarded-Port` came from a proxy in `trustedProxies` — when `Host` carries no port, a client-supplied value is passed through.
Treat the forwarded authority as client-influenced input. A service that builds password reset links, absolute redirects, or cached URLs from it should pin its own public origin in configuration rather than deriving one from request headers. This is not specific to `X-Forwarded-Port`: the `Host` header has always carried a client-chosen port.
## Environment variables
The listen address and public base URL can also be set via environment variables, which take precedence over `config.json`:
Paseo surfaces terminal activity as a tab indicator (the same "running" dot used by agents).
## Current state
Terminal activity is source-agnostic plumbing. `TerminalActivityTracker` holds the current per-terminal state and emits transitions to the manager, worker protocol, websocket subscription, app buckets, dots, and notifications.
The tracker defaults to unknown (`null`). Activity production lives outside terminal stream parsing: agent hook commands report coarse activity to the daemon's local `/api/terminal-activity` endpoint.
## Architecture
```
TerminalSession
├── TerminalActivityTracker one per session
│ ├── set(state) records the latest state
│ └── onChange(snapshot, previous) fires only on resolved-state transitions
│
└── onActivityChange({ activity, previous }) subscribed in TerminalManager
├── emits terminalsChanged terminal list/tab indicators only
└── subscribeTerminalActivity per-transition stream for notification policy
└── subscribeTerminalWorkspaceContributionChanged workspace status rollup only
```
`TerminalActivityTracker` is the single stateful object per session. It holds `{ state, changedAt }`, starts at unknown (`null`), and fires `onChange` only when the state actually changes.
Terminal directory snapshots (`terminalsChanged`) and workspace contribution changes are separate concerns. A title-only change produces a terminal list snapshot but never touches workspace descriptors. A transition that changes the derived workspace bucket (e.g. idle -> working, working -> idle, attention cleared) emits both a terminal list snapshot and a server-internal `TerminalWorkspaceContributionChanged` event, which Session consumes to invalidate every active workspace sharing the owning workspace's `cwd`.
### Transitions carry their own history
Each `onChange` delivers both the new snapshot and the `previous` one (`{ state, changedAt }`). The transition flows unchanged up through `TerminalSession.onActivityChange` (as `{ activity, previous }`), the worker protocol's `terminalActivityChange` event, and the manager-level `subscribeTerminalActivity(listener)` stream (`{ terminalId, name, cwd, activity, previous }`).
The daemon consumes these transitions, not snapshots. When a transition moves from `working` to `idle`, the tracker records finished attention, so the terminal shows the same green finished dot as an idle agent that needs review. The websocket layer also fires a "Terminal finished" attention notification. A terminal that exits while still working emits no turn-end notification.
Terminal list visibility is `workspaceId`-scoped: a terminal belongs to the workspace that created it, and same-`cwd` sibling workspaces do not see it in their terminal lists. Terminal status routing starts from that owning workspace, uses the owning workspace's `cwd`, then fans the status bucket out to every active workspace with the same `cwd`.
Path-prefix routing is only a legacy fallback for unowned terminal activity contribution. If a live terminal has no `workspaceId`, the daemon resolves the deepest active parent workspace from the terminal `cwd`, then fans status out to active same-`cwd` siblings of that owner. That fallback contributes status, but it does not make the terminal visible in workspace-scoped terminal lists.
## Hook reporting
Terminals receive four environment variables when the daemon creates the shell:
- `PASEO_TERMINAL_ID`
- `PASEO_ACTIVITY_TOKEN`
- `PASEO_TERMINAL_ACTIVITY_URL`
- `PASEO_HOOK_CLI` — absolute path to the current `paseo` CLI executable.
The generated shell command uses `PASEO_HOOK_CLI` to run the current CLI. `paseo hooks <agent> <event>` then reads the terminal id, token, and activity URL, asks the agent hook provider registry to resolve the event to a coarse activity state, and silently posts `{ terminalId, token, state }` to the activity URL. Missing env, unsupported agents/events, malformed hook input, and daemon/network failures are no-ops so agent hooks never break the user's terminal session.
Claude hook mapping:
- `UserPromptSubmit` → `running`
- `Stop`, `StopFailure`, `SessionEnd` → `idle`
- `Notification` with `notification_type` equal to `idle_prompt` → `needs-input`
Claude does not run `Stop` when the user interrupts a turn. A standalone Ctrl-C or Escape input
while terminal activity is working clears the activity without finished attention. The same
fallback applies to every hooked terminal agent; exact input matching excludes escape sequences and
pasted content, while later provider idle events remain authoritative.
Codex hook mapping:
- `UserPromptSubmit` → `running`
- `PreToolUse`, `PostToolUse` → `running`
- `PermissionRequest` → `needs-input`
- `Stop` → `idle`
OpenCode uses a server plugin instead of command hooks. Both generations discover the same global plugin file. Their loaders select separate entrypoints: OpenCode 1 calls `server()` with `{ type, properties }` bus events; OpenCode 2 calls `setup()` and subscribes to decoded `{ type, data }` events. Do not share their status mapping: V1 publishes `session.status` snapshots, while V2 publishes `session.execution.*` transitions.
| `session.status` with `busy` or `retry` | 1 | running |
| `session.status` with `idle` | 1 | idle |
| `session.execution.started` | 2 | running |
| `session.execution.succeeded`, `.failed`, or `.interrupted` | 2 | idle |
| `permission.asked` | Both | needs-input |
| `permission.replied` | Both | running |
The plugin translates both event contracts into the existing Paseo hook events. OpenCode 2 disposes its event subscription when the plugin unloads.
The daemon maps hook states onto terminal activity like an agent lifecycle plus unread attention: `running` → `state: working`, `idle` → `state: idle`, and `needs-input` → `state: idle` with `attentionReason: needs_input`. A `working` → `idle` transition records `state: idle` with `attentionReason: finished` until the user focuses that terminal; plain idle terminals still contribute no workspace status.
## Focus clearing
Client heartbeats include the focused terminal id. When a visible client focuses a terminal with an `attentionReason`, the daemon clears the attention and leaves the terminal idle. Plain idle terminal activity does not contribute to workspace status, so a workspace whose only attention source was that terminal rolls up from `needs_input` or `attention` back to `done`.
### Agent hook installation
Installing hooks edits the user's real agent config files, so it is opt-in. The daemon setting
`enableTerminalAgentHooks` (persisted under `daemon.enableTerminalAgentHooks`, default `false`)
gates installation. It is surfaced in the app under a host's **Terminals** settings as "Enable
terminal agent hooks" — "Get notifications and status from terminal agents. This installs hooks in
your agent config files." `applyTerminalAgentHookSetting` reconciles the installed hooks with the
setting: at startup it installs only when enabled; toggling the setting live installs on enable and
removes Paseo's marker-matched hooks on disable. `paseo hooks` keeps working regardless — the gate
only controls whether the daemon writes hooks into agent configs, not whether the CLI can post
activity when the env is present.
When enabled, Paseo installs provider hooks globally:
- Claude hooks are written to `~/.claude/settings.json` (or `CLAUDE_CONFIG_DIR/settings.json` when that override is set).
- Codex hooks are written to `~/.codex/hooks.json` (or `CODEX_HOME/hooks.json` when that override is set). Codex supports a native `commandWindows`, so each Paseo hook includes both POSIX and Windows commands. Non-managed Codex hooks are trust-gated by Codex; users may see Codex's hook review prompt before the hook runs.
- OpenCode gets a self-contained plugin at `$XDG_CONFIG_HOME/opencode/plugins/paseo-terminal-activity.js` (or `~/.config/opencode/plugins/paseo-terminal-activity.js` when XDG is unset; `OPENCODE_CONFIG_DIR` still wins when set).
Installation is marker-based/idempotent for config hooks and exact-file/idempotent for the OpenCode plugin. Paseo preserves user hooks, removes only its own marker-matched command hooks, and leaves hooks installed across daemon shutdown. Outside a Paseo terminal they are inert because the command or plugin is gated on `PASEO_TERMINAL_ID`.
Provider variation lives in `AGENT_HOOK_PROVIDERS`: provider id, installed events, config install metadata, and runtime event-to-activity resolution. The daemon calls `installRegisteredAgentHooks()` once; the CLI calls `resolveHookActivity(provider, event, input)`. Adding a provider should add one provider entry and register it in `AGENT_HOOK_PROVIDERS`, without editing the generic CLI command or daemon bootstrap.
The installed hook command keeps the config portable and resolves the CLI at runtime:
```sh
[ -n "$PASEO_TERMINAL_ID" ] && "${PASEO_HOOK_CLI:-paseo}" hooks claude <event>
```
Codex also receives the Windows equivalent:
```bat
if defined PASEO_TERMINAL_ID (if defined PASEO_HOOK_CLI ("%PASEO_HOOK_CLI%" hooks codex <event>) else (paseo hooks codex <event>))
```
The daemon resolves the current CLI through `PASEO_CLI` when its launcher supplies one, or through the npm package shim for standalone installs. Terminal setup exposes that resolved executable to hooks as `PASEO_HOOK_CLI`; desktop and other daemon launchers do not know about the hook-specific variable. The generated command falls back to bare `paseo` if the hook env is missing and no-ops outside Paseo terminals because the `PASEO_TERMINAL_ID` gate remains first. Paseo also prepends the resolved CLI directory to each terminal `PATH` as a secondary fallback. All other behavior lives in `paseo hooks`: read the env, map the event, POST activity, and no-op/fail-open when anything is missing or unavailable.
If config installation fails, daemon startup and terminal spawn continue without terminal activity hooks.
How terminal output stays low-latency, what the invariants are, and how to measure before/after any change to the pipeline. Read this before touching anything under `packages/server/src/terminal/` or `packages/app/src/terminal/runtime/`.
Terminal frames share the daemon main event loop with all agent traffic. The `eventLoopDelay` block in the `ws_runtime_metrics` log line (every 30s in `daemon.log`) is the ground truth for "the daemon is busy" — p99/max there directly bound worst-case terminal frame delay.
## Invariants (the easy-to-break ones)
- **Coalescers are leading+trailing throttles.** The first chunk after an idle window flushes immediately (synchronously); only sustained bursts wait for the trailing timer. Reverting to trailing-only adds a full window (~5ms) to every keystroke echo.
- **Output coalescing happens in the worker, before IPC.** One `process.send` per pty chunk was a main-loop flood under build output. Non-output messages (snapshot/snapshotReady/titleChange/exit) must flush the coalescer first so ordering is preserved.
- **Coalesced output carries the LAST chunk's revision.** Snapshot replay dedup (`replayTerminalOutputAfterSnapshot`) skips buffered output with `revision <= replayRevision`; a merged batch with a lower revision would be wrongly skipped (lost output).
- **The input-mode tracker runs once per process boundary, not per hop.** The worker owns the authoritative tracker; the daemon caches the replay preamble from `getTerminalState` responses and `snapshotReady` messages. Do not reintroduce a per-chunk `feed()` on the daemon main loop.
- **Snapshot catch-up is backpressure-gated.** A stream falls back to a full snapshot only when `outputBytesSinceSnapshot > MAX_TERMINAL_OUTPUT_FRAME_BYTES` (256KB) **and** the client transport reports `bufferedAmount > MAX_CLIENT_BUFFERED_BYTES` (4MB). A client that keeps draining streams continuously, no matter how much output is produced. Before this gate existed, every 256KB of build output dropped a frame and forced a full JSON cell-grid snapshot (~200k objects across IPC) — the historical source of spiky lag and GC hitches.
- **Plugin daemon sessions report IPC queue bytes.** Their virtual socket increments `bufferedAmount` before `process.send` and decrements it only in the send callback. Text and binary frames share that ordered queue, so the normal snapshot catch-up and physical high-water gates remain valid for server-side plugin SDK traffic.
- **Client output writes are not serialized per frame.** The emulator runtime drains contiguous plain writes straight into xterm (which buffers internally). Only barrier ops (`clear`, `snapshot`, `suppressInput` writes) wait — behind a zero-length sentinel write — so resets can't interleave with in-flight output.
- **Find preserves the inspected xterm viewport, including at the live bottom.** Keep its output anchor inside the existing batched write path. Serializing output to preserve a search result would add terminal latency.
- **Retained terminal tabs in the focused workspace keep their streams.** Hidden mounted terminals continue applying output, so switching tabs does not resubscribe or request a fresh snapshot. The retained-panel LRU bounds the number of live streams; terminals in an unfocused workspace detach.
- **Terminal size has one daemon-owned claimant.** Focus and direct interaction send a `claim`; later geometry changes from that connection send `update`. A claim transfers ownership even when the dimensions are unchanged, while an update from any other connection is ignored. This lets an owning pane follow splits and keyboard insets without allowing an idle phone or browser to steal the PTY size.
## Measuring
- **Node-only benchmark (fast iteration, server pipeline):**`npx tsx scripts/benchmark-terminal-latency.ts`. Boots an isolated daemon (fresh `PASEO_HOME`, random port — never 6767), measures echo latency percentiles, burst jitter, and snapshot counts under ramped mock-agent load. Writes JSON to `/tmp/paseo-terminal-bench/`. Healthy numbers (2026-06): echo p50 ~2.3ms, p95 ~3.3ms, a 2MB burst fully streamed with `snap=0`.
`packages/app/e2e/browser/terminal-performance.spec.ts` and `packages/app/e2e/browser/terminal-keystroke-stress.spec.ts` (per-stage keydown→xterm-commit breakdown under mock-agent load). Healthy: keydown→commit p50 ~18ms under 600-key burst.
- **Production:** grep `daemon.log` for `ws_runtime_metrics` and read `eventLoopDelay` + `bufferedAmount`.
- **Git pressure:** the same log line includes `git.commands` (limiter occupancy, queue age,
queue wait, execution time, failures, timeouts, and top operations),
`git.workspaceService` (daemon-global Git observer ownership), and per-session workspace Git
subscription totals under `runtime`. Queue wait and execution time are separate because the Git
command timeout begins only after a command acquires a limiter slot.
## Known remaining contention (follow-up candidates)
- A single large `agent_stream` message (e.g. a 250KB diff payload) measurably delays terminal echo (~100ms-class dips) — cost is split between daemon serialization and app-side parse/render on the shared browser main thread. See [agent-stream-performance.md](agent-stream-performance.md) for that pipeline's own budgets.
- Relay-attached clients pay pure-JS tweetnacl encryption on the daemon main loop (`packages/relay/src/encrypted-channel.ts`). Negotiated binary application frames stay binary ciphertext and avoid base64 encode/decode; text and mixed-version traffic remain base64 WebSocket text frames.
- `sendToClient` re-stringifies session messages per socket; only matters for multi-socket connections.
Writing all tests first then all implementation produces bad tests — you end up testing imagined behavior instead of actual behavior.
## Fallible user actions
Every user action that can fail must expose the complete operation state in the UI:
- **Pending:** show immediate progress and prevent accidental duplicate submissions.
- **Success:** show the requested result, or a clear success acknowledgement when the result is not otherwise visible.
- **Failure:** keep an actionable error visible in the same context until the user retries or dismisses it.
Logs, console output, and a reset button are not user feedback. Neither is a platform API unless it is verified on every supported platform: React Native Web's `Alert.alert()` is a no-op, so browser and Electron failures must use rendered app UI such as the shared alert component.
Every fallible action needs behavioral coverage for success and failure. RPC-backed UI should use an app Playwright test with a real browser, network, and daemon whenever feasible. The failure test must assert what the user can see and do after the failure, not an internal response, state field, or log line. Add distinct timeout or disconnect cases when they produce distinct recovery behavior.
## Determinism first
Tests must produce the same result every run:
@@ -91,6 +103,51 @@ function createTestEmailSender() {
When a test is labeled end-to-end, it calls the real service. No environment variable gates, no conditional skipping, no mocking the external dependency.
### Packaged desktop smoke
The packaged desktop smoke is an external observer of the production launch path. It must not add a smoke-only branch to Electron main or start the daemon itself.
The harness launches the packaged app with isolated user data and daemon state, connects to the real renderer over Chromium's debugging protocol, and requires all of these outcomes:
- the `paseo://app/` renderer mounts into `#root`;
- the sandboxed preload exposes the desktop bridge;
- the renderer starts a fresh desktop-managed daemon through the normal startup bootstrap;
- the bundled CLI can query that daemon and run a terminal command.
The Desktop Packages workflow runs the Linux x64 smoke under Xvfb on main pushes and on pull requests that change `packages/desktop/**`, `nix/**`, or the workflow itself. The Linux job is pinned to Ubuntu 24.04 and runs twice: with AppArmor user namespace restrictions enabled, then with user namespaces available. It installs the real `.deb`, launches the real AppImage via `--appimage-extract-and-run`, and launches the extracted tar archive. It also replaces the Debian installation with the generated RPM through `rpm --install --nodeps` and checks its sandboxed launch under restrictions. Ubuntu supplies the runtime libraries under Debian package names, so this verifies the RPM payload and postinstall rather than Fedora dependency resolution. Each launch verifies the reported sandbox decision; enabled renderers must also have `NoNewPrivs: 1` and `Seccomp: 2` in `/proc`. The desktop release matrix retains its host-native smokes. Linux release builds stay on Ubuntu 22.04 to preserve their native-library baseline; the restricted-host regression runs on Ubuntu 24.04 after merge.
Never repair `chrome-sandbox` in the smoke harness. The old unpacked smoke set its mode to 4755 and concealed a broken package installer. Run installer tests as root and launch tests as an ordinary user: a root-run namespace probe does not reproduce Ubuntu's AppArmor policy for desktop users. Preserve both restricted and unrestricted cases; either one alone permits another sandbox regression.
Smoke jobs upload renderer screenshots and desktop/daemon diagnostics, including successful Linux sandbox evidence.
npm run build:desktop -- --publish never --linux --x64 --dir
```
### Undeclared peer dependencies break app.asar
electron-builder packs `node_modules` by walking declared production `dependencies`. A package that imports something it only lists as a `peerDependency` resolves fine in this hoisted workspace, passes every test, and then throws `ERR_MODULE_NOT_FOUND` inside `app.asar` — killing the desktop daemon at startup. That shipped twice from `@replit/codemirror-lang-*` grammars, which are interactive editor extensions published as if they were bare parsers.
The packaged smoke catches it after merge. PRs need the fast dependency-closure test below because packaging runs on main.
`packages/highlight/src/__tests__/dependency-closure.test.ts` replicates the packer's traversal statically and runs with the normal unit tests. It is scoped to `@getpaseo/highlight` on purpose: that tree is small and pure, so the check is exact. Running the same walk over `@getpaseo/server` produces dozens of false positives from optional dependencies loaded behind `try`/`catch`.
Prefer a `@lezer/*` grammar. When a language only ships inside an editor extension, vendor the grammar into `packages/highlight/src/<lang>/` — see `svelte/`, `nix/`, and `csharp/`.
### Desktop browser regression
The desktop browser E2E launches an isolated real daemon, Metro, and Electron app. It forces workspace LRU eviction to reparent the original tab and replace its guest `WebContents`, then makes one MCP call each for tab listing, snapshot, and click against that original browser id. A final MCP wait proves the real target page received the click.
Run it locally with the same command owned by the Ubuntu `desktop-tests` required check:
```bash
npm run test:e2e:browser-tabs --workspace=@getpaseo/desktop
```
## Test organization
- Collocate tests with implementation: `thing.ts` + `thing.test.ts`
@@ -111,10 +168,12 @@ Vitest picks up tests by suffix. The suffix tells the runner which category it b
| `*.real.e2e.test.ts` | E2E that hits a real provider (Claude/Codex/Copilot/OpenCode/Pi) — needs creds in `packages/server/.env.test` | `npm run test:integration:real` / `test:e2e:real` |
| `*.local.e2e.test.ts` | E2E that needs a local-only resource | `npm run test:integration:local` / `test:e2e:local` |
App-level Playwright browser E2E lives in `packages/app/e2e/*.spec.ts` and runs via `npm run test:e2e --workspace=@getpaseo/app` (separate from Vitest E2E). App Playwright specs that hit real providers use `*.real.spec.ts` and run through `npm run test:e2e:real --workspace=@getpaseo/app`; the default app E2E project ignores that suffix so CI does not need provider credentials.
Browser Playwright specs live in `packages/app/e2e/browser/`. Desktop Playwright and real-Electron E2E live in `packages/desktop/e2e/`. Harness code shared by both suites lives in `packages/app/e2e/support/`; neither suite may place specs there. App Playwright specs that hit real providers use `*.real.spec.ts` and run through `npm run test:e2e:real --workspace=@getpaseo/app`; the default browser project ignores that suffix so CI does not need provider credentials.
Live provider smoke tests belong in `*.real.e2e.test.ts`, not `*.test.ts`, even when guarded by environment variables. Default unit suites must use deterministic provider adapters/fakes so missing credits, auth outages, and upstream model drift do not block normal CI.
Codex MultiAgentV2 real tests use local Codex authentication rather than the OpenRouter-compatible test provider. OpenRouter does not accept Codex collaboration-history items on the parent follow-up request, so it cannot verify a complete native sub-agent turn.
### Test setup
- Server: `packages/server/src/test-utils/vitest-setup.ts` loads `.env.test`, sets `PASEO_SUPERVISED=0`, and disables Git/SSH prompts. Add new global env shims here, not in individual tests.
@@ -130,6 +189,22 @@ Test suites in this repo are heavy. Running them in bulk freezes the machine, es
- Never re-run a suite another agent already reported green.
- For full-suite confidence, push to CI and check GitHub Actions.
- Never run the full Playwright E2E suite locally — defer whole-suite verification to CI. Targeted Playwright specs are allowed when you changed or need to prove that specific flow.
- App Playwright shares one warmed Metro server per run and gives every Playwright worker its own isolated daemon and `PASEO_HOME`. Spec files run concurrently without exposing one file's projects, agents, terminals, history, or provider configuration to another worker; tests within a file remain together so file-level setup is not repeated.
- Playwright specs that exercise only the daemon import `daemonTest` from the shared fixtures so they do not create a browser context or page.
- Helpers that create projects or workspaces own those records until cleanup. Their clients remove the daemon project on close, and an automatic fixture fails any test that still leaks a project record. Deleting only the temporary directory is not cleanup. Agent helpers pass the intended `workspaceId` through to agent creation; they never infer ownership from `cwd`.
- Tests whose subject is daemon-global state, such as an empty history or daemon restart, start a dedicated host explicitly. Filenames and directories describe product behavior, never execution order or isolation mechanics.
- Global setup accepts Metro as ready only when `/status` returns `packager-status:running`, then fetches the document's scripts so the cold bundle compilation finishes before Playwright's per-test timeout starts. A generic TCP listener is not sufficient readiness evidence. The browser suite uses direct local daemon connections and does not start a relay.
- The app Playwright harness boots on Windows as well as POSIX. Spawn Node entrypoints through `process.execPath`, not `npx` or `node_modules/.bin` shims: Node refuses to spawn `.cmd` or `.bat` without `shell: true`, and shell mode concatenates argv without escaping and sends kill signals to `cmd.exe` instead of the real child.
- Teardown kills the process tree, because a Windows signal reaches only the direct child and leaves forked workers holding the listening port.
- The `asdf`-backed local Elixir relay stays POSIX-only, so the `relay-deployment` Playwright project is unavailable on Windows.
## Pull-request test routing
PR checks are routed by the behavior each suite proves, using `.github/ci-paths.yml`. A package does not inherit every test suite of its runtime consumers: app changes do not run CLI or Electron-wrapper tests, and protocol changes do not run every package that imports the protocol. Cross-package static compatibility belongs to `typecheck`; full integration coverage runs after merge on main and in manual CI runs.
Required matrix legs are declared as statically named jobs. Their shared steps use YAML anchors, while job-level `if` conditions let GitHub report an unaffected leg as genuinely skipped without allocating a runner or losing the exact required-check name.
The smallest meaningful contract wins over package ownership. Tiny structural invariants such as daemon launch supervision run unconditionally in the always-running routing job instead of maintaining a transitive file list; this check reads source entrypoints and builds no product. Routed integration contracts use stable domain directories. Browser changes select the required Playwright shards; desktop changes select the existing required desktop jobs, with renderer and real-Electron coverage together in the Ubuntu leg. Packaging runs on main in the Desktop Packages, Docker, and Nix workflows. CLI-side Hub changes select one focused test inside the existing required server jobs. The CLI source suite runs once in the third required CLI job; only the separate local E2E runner is sharded, and that runner owns its dependency build. Repository scripts and the shared Vitest configuration run every PR contract because they are cross-cutting toolchain inputs.
@@ -5,9 +5,21 @@ Agent chat delivery has two paths:
1. **Live stream** — `agent_stream` WebSocket messages for immediacy. These may be delta-shaped lifecycle updates.
2. **Authoritative history** — `fetch_agent_timeline_request` for correctness. This always returns full projected timeline items, never lifecycle deltas.
The invariant is:
The daemon retains projected items in memory. Each source event advances the stream sequence,
then replaces the previous tool state or merges into the current text item. Intermediate payloads
are never retained for history or catch-up. Provider history is the durable transcript authority
and rebuilds the projection when an agent resumes.
> If the daemon has committed timeline rows for an agent, any connected client that opens or resumes that agent eventually displays every row through the daemon's current tail.
The invariants are:
> A continuously subscribed client applies every committed row in order. Opening or resuming an
> agent establishes the daemon's current tail in one bounded request, with older history reachable
> through backward pagination.
Tool output is bounded before it enters either delivery path. Canonical shell tool output is sliced
to 64 KiB, and the same bounded item is used for runtime timeline rows and live stream events.
Provider history hydration applies the same rule so reopening an agent cannot restore an oversized
Heartbeat is used for notification routing. It must not be used as a correctness gate for `agent_stream` delivery. A stale mobile focus heartbeat may affect whether the user gets notified; it must not make timeline rows disappear from the live stream.
## Catch-up is paged but complete
## Gap recovery is paged but complete
Large unbounded timeline responses can exceed relay frame limits, so catch-up uses bounded pages. Bounded does not mean partial.
Page limits are projected-item targets. A tool call lifecycle is one projected item even if it spans many source sequence numbers, and assistant/reasoning chunks are merged before counting. The response carries `seqStart`, `seqEnd`, `sourceSeqRanges`, and `collapsed` so clients can advance sequence cursors without rendering delta rows.
When the app fetches `direction: "after"` and the daemon responds with `hasNewer: true`, the app must immediately fetch the next page from `endCursor`. The catch-up is complete only when `hasNewer: false`.
When live delivery detects a sequence gap, the app fetches `direction: "after"`. If the daemon
responds with `hasNewer: true`, the app immediately fetches the next page from `endCursor`. Gap
recovery is complete only when `hasNewer: false`.
The first load of an agent without a local cursor is different: it fetches a bounded latest tail page. Older history remains user-driven by scrolling upward.
Initialization timeouts guard lack of catch-up progress, not the full multi-page sync. A successful page that queues the next `after` page refreshes the watchdog.
Opening or resuming an agent fetches one bounded latest tail page. Older history remains
user-driven by scrolling upward.
A failed catch-up or subscription reconcile retries on its own, doubling from 1s to a 30s ceiling.
A fixed 1s retry turned a persistent daemon-side refusal — a Codex thread that already has an active
writer, say — into a request and a log line every second on an idle app. The delay resets on success,
reconnect, delivery-mode change, and visibility change, so recovery is still immediate once the
condition clears. Republishing the same visibility set is a no-op and never bypasses backoff.
Background retries are silent. The retry the user presses in the sync-error callout is a fallible
user action and owns its pending state: `retrying` is a status the sync model publishes, not a React
boolean, so the button reports in-flight and the callout returns to `error` when the attempt fails.
Reaching the history-start threshold loads one older page and preserves the visible content anchor.
Cursor progress does not trigger another page. The user must leave and return to the threshold unless
the anchored page still leaves the viewport at history start, as with short or compacted content; in
that case pagination continues as one loading operation until the page fills the viewport or history
is exhausted.
## Durable item anchors
Provider message IDs are not guaranteed for every displayed item. Paseo-generated system errors are one example. Rendered item indices are not durable either because pagination and projection can merge source rows.
Actions that address a point in chat history, such as Fork, use the daemon timeline `epoch` plus the projected item's `seqEnd`. The app carries that position on the rendered assistant item for both live and fetched history. When adjacent projected chunks merge, the merged item retains the newer chunk's position.
The daemon validates the epoch and locates the projected item at the selected position. A fork
includes projected items through that checkpoint. If an item spans the checkpoint and changed
afterward, the daemon refuses that fork with an actionable error: discarded historical payloads
cannot be reconstructed from sequence metadata. Forking the current context remains available.
## Resume behavior
When a client resumes with a known cursor, it catches up after that cursor to completion. It does not replace the view with a latest tail page, because tail pagination can skip the middle of a long background run.
Opening, reconnecting, and returning from app background establish the current timeline through
bounded catch-up. Switching between continuously subscribed open chats needs no fetch.
Focus alone does not mutate timeline state; the response is compared with the local
authoritative range first.
When a client resumes without a cursor, it fetches the latest tail page.
Cached history remains readable during recovery. The chat shows Reconnecting to host while the host is
offline, then Updating messages until authoritative catch-up completes. Socket connectivity alone cannot
certify that the displayed conversation is current. The timeline owner publishes freshness; the
view renders it without a toast timer or a separate resume workflow.
The draft-create handoff has the same lifetime: the viewed-timeline owner releases it when the sync
stops owing that chat a catch-up, not when the first authoritative page lands. Releasing it at the
first page leaves a chat this client just created looking hydrated but still catching up, which
shows Updating messages for a conversation that cannot be out of date. Closing the tab ends the
obligation too, so a reopened chat starts from authoritative state instead of a stale optimistic
one; disconnect and backgrounding keep it.
Foregrounding probes a nominally connected session immediately. A healthy response preserves the
socket; a failed three-second probe starts reconnecting without waiting for the background heartbeat
or retry backoff. This cannot keep a mobile socket alive after the operating system suspends it.
- The same epoch and `window.maxSeq` is an exact display no-op. The app advances synchronization
bookkeeping without replacing timeline arrays, preserving an upward-scrolled viewport.
- When the page overlaps or is adjacent to the local end cursor, only projected items newer than
that cursor are applied. Already-covered rows are not replayed.
- A true middle gap, epoch change, or rewind atomically replaces stale canonical history with the
latest tail. The replacement reconciles positioned live rows beyond its coverage and unresolved
local submissions; it never retains two discontiguous canonical ranges.
The installed tail carries `hasOlder`, so history skipped by a replacement remains reachable through
ordinary backward pagination. A backward page is accepted only when it is adjacent to the current
history start; a response requested from a pre-replacement range is stale and is discarded.
A plan approval keeps the original proposal's tool-call identity through resolution and provider
history replay. The pending approval UI can hide that tool from presentation, but the client model
must retain its position. Creating a new history card on rejection places it after the prompt that
rejected it; changing steer-event ordering would also put new assistant output before that prompt.
## Provider child history
Child transcripts use the same projected-page reconciliation as the main conversation. The client
retains rendered items and sequence cursors, never a second cache of source events. Pagination
uses the projected display anchor; live updates advance the source cursor without moving tools.
Clients advertising `projected_subagent_timeline` receive child streams and projected fetches.
Updated clients require `features.projectedSubagentTimeline` for child history; older hosts show
an update-host notice for that pane.
Older clients retain child names/status and receive an upgrade message when opening a child
conversation. This gate affects only the child transcript; it does not gate the connection,
main conversation, or current-context forks. A legacy `canonical` root request still receives
projected items.
## Client replica lifetime
The session projection remains host-scoped for as long as the host is registered. The viewed-timeline
Paseo starts with adaptive themes, then applies the persisted theme after async settings load. A
module-level read can therefore materialize the light style before a persisted dark theme is
active. If the view mounts after that theme change, React Native receives the stale light object;
Unistyles registers the node for future changes but does not retroactively replace its initial
props. Settings dividers once rendered light `#e4e4e7` inside a dark `#252B2A` card for exactly
this reason.
Render-time array syntax is intentional and exempt from the app's JSX array-allocation lint rule.
Keep the entries separate so each retains its Unistyles metadata. If composition is needed outside
JSX, create the array inside the component or in a `useMemo` that first runs when the component
mounts—never at module evaluation time.
[`useUnistyles()`](https://www.unistyl.es/v3/references/use-unistyles) is different. It gives React access to the current theme/runtime and can make a component re-render when those values change. Use it for values that must be rendered through React props, such as icon colors or small escape hatches. Do not expect direct reads from `UnistylesRuntime` to re-render a component; [issue #817](https://github.com/jpudysz/react-native-unistyles/issues/817) is a useful reminder of that invariant.
## Dynamic Pixel Styles On Web
@@ -90,9 +114,27 @@ When a reusable component has a prop whose whole job is dynamic geometry, make t
Do not flatten a caller-provided style array and pass the flattened object back to a React Native component. Unistyles style entries carry `unistyles_*` metadata; flattening two entries produces one object with multiple metadata keys and triggers the runtime warning: "use array syntax instead of object syntax." Preserve caller styles as arrays, and only flatten the dynamic geometry value you explicitly own. If that owned value was flattened from a mixed style prop, strip `unistyles_*` metadata before sending it through `inlineUnistylesStyle`.
Do not register an existing Unistyles style inside another `StyleSheet.create` either. That also combines two metadata identities into one object. Reuse the original style directly at the component:
// Right: one registered style identity reaches the native view.
<View style={sharedStyles.row} />;
```
This mistake once produced tens of thousands of warnings from retained sidebar rows. Because React Native captures component stacks for warnings, the warning loop itself can consume enough CPU and memory to make the app appear blank.
## Main Gotcha: `contentContainerStyle`
`ScrollView.contentContainerStyle` is the canonical trap. It looks like a style prop, but it is not the same prop that Unistyles' remapped native component registers by default. The upstream tutorial calls this out directly in its [ScrollView Background Issue](https://www.unistyl.es/v3/tutorial/settings-screen#scrollview-background-issue) section.
`ScrollView.contentContainerStyle` is the canonical trap. It looks like a style prop, but it is not the `style` prop Unistyles registers. How badly that bites depends on who owns the component:
- **A remapped React Native `ScrollView`** still picks the style up — the Babel plugin rewrites the import and the factory tracks `contentContainerStyle` alongside `style`. The failure mode here is staleness: on first mount it can paint with the theme that was active at mount time and never repaint. The upstream tutorial calls this out in its [ScrollView Background Issue](https://www.unistyl.es/v3/tutorial/settings-screen#scrollview-background-issue) section.
- **A third-party scroller** — `BottomSheetScrollView`, `FlashList`, anything Unistyles never rewrote — drops the style entirely on web. Nothing throws and the value looks right in JS, but the class Unistyles emitted is never applied, so the padding, gap, or background is simply absent. `AdaptiveModalSheet` shipped its compact sheet content flush to the screen edges for exactly this reason while the desktop card, which uses a real `ScrollView`, looked correct.
The second case is why the wrapper-`View` fix below is not optional styling advice. A themed inset belongs on a `View` you own; a third-party scroller only gets theme-free layout.
Avoid this pattern when the style depends on the theme:
On first mount this can paint with the current adaptive or initial theme. If app settings later load a persisted theme and call [`UnistylesRuntime.setTheme`](https://www.unistyl.es/v3/guides/theming#change-theme), the JS-side style proxy may report the new theme while the native content container keeps the old background. That is how the welcome screen ended up with a light background and dark foreground/buttons.
If app settings later load a persisted theme and call [`UnistylesRuntime.setTheme`](https://www.unistyl.es/v3/guides/theming#change-theme), the JS-side style proxy reports the new theme while the native content container keeps the old background. That is how the welcome screen ended up with a light background and dark foreground/buttons.
This applies broadly to non-`style` props that carry theme-dependent values, such as component props named `color`, `trackColor`, `tintColor`, `backgroundStyle`, `handleIndicatorStyle`, and other library-specific style props. The [3rd-party view decision algorithm](https://www.unistyl.es/v3/references/3rd-party-views) recommends explicit handling for these cases, and [issue #1030](https://github.com/jpudysz/react-native-unistyles/issues/1030) shows a related native-prop update edge case around `Image.tintColor`. Treat these values as React props unless wrapped with `withUnistyles`.
@@ -293,19 +335,33 @@ If we ever need to avoid the transition entirely, store at least the theme prefe
## Runtime Theme Patching For User Preferences
Appearance settings (UI/mono font family, font sizes, syntax-highlight theme) are applied by patching every registered theme at runtime with `UnistylesRuntime.updateTheme(name, updater)` — not by threading preference reads through components. `applyAppearance` in `packages/app/src/screens/settings/appearance/apply-appearance.ts` runs from a `ProvidersWrapper` effect on settings load/change and loops all six theme keys, returning `{ ...theme, fontFamily, fontSize, lineHeight, colors.syntax }`.
Appearance settings (theme, UI/mono font family, font sizes, syntax-highlight theme) are owned by `packages/app/src/appearance`. Its provider subscribes once and synchronizes Unistyles when settings or plugin contributions change. `applyAppearance` patches every key in `REGISTERED_THEMES`, returning `{ ...theme, fontFamily, fontSize, lineHeight, colors.syntax }`.
This works without `useUnistyles()` because every consumer already reads these tokens through `StyleSheet.create((theme) => …)` (or the `withUnistyles`/`uniProps` path for the markdown renderer), so patching the theme repaints tracked views through the native ShadowRegistry with no React re-render.
Gotchas:
- **Patch all themes, not just the active one.** The active theme can change and adaptive mode can flip light/dark; patching every key keeps the active key current and makes ordering vs `setTheme`/`setAdaptiveThemes` irrelevant. The effect depends on the settings values (not on `theme`), so it cannot loop.
- **The reserved plugin keys are the exception to that ordering.** A plugin-contributed theme is rebuilt from its palette, which discards the appearance patch, so the appearance provider writes the matching light or dark slot before applying font and syntax preferences. See [plugins.md](plugins.md).
- **Narrow the discriminated union before spreading.**`updateTheme`'s updater returns the theme union; spreading the union widens `colorScheme` to `"light" | "dark"`, which is assignable to neither concrete member. Branch on `t.colorScheme` so each branch spreads a single narrowed theme type (no `as`).
- **`lineHeight.diff` is the code/diff line-height axis** — it is coupled to the code-font-size control (≈ `codeFontSize * 1.5`). Do NOT use it for prose. Markdown body line-height scales with the UI ramp (`Math.round(theme.fontSize.base * 1.4)`); routing prose through `lineHeight.diff` clips text at small code sizes.
- **`lineHeight.diff` is the code/diff line-height axis** — it is coupled to the code-font-size control (≈ `codeFontSize * 1.5`). Do NOT use it for prose. Markdown body line-height scales with content size (`Math.round(theme.fontSize.content * 1.4)`); routing prose through `lineHeight.diff` clips text at small code sizes.
- **High-churn draft values** (live-while-typing in the appearance preview) bypass the theme: apply them as inline styles marked with `inlineUnistylesStyle` so per-keystroke values don't grow the `#unistyles-web` CSS registry.
- **Mounted parsed content uses `AppearanceStyleBoundary`.** Markdown, syntax-highlighted code, and tool-call detail bodies can contain memoized/custom renderer trees that do not naturally re-run when runtime-patched appearance tokens change. Wrap the parsed surface once with `packages/app/src/components/appearance-style-boundary.tsx`; do not add local "appearance key" props at each callsite.
- **Web numeric appearance tokens need a render; native tracked styles update in place.** On web, string tokens such as colors become CSS variables, but numeric tokens (font sizes, line heights) are baked into generated classes. `AppearanceStyleBoundary` remounts web shell chrome and explorer content to refresh those classes. Native sidebars and explorer hosts stay outside appearance keys: their gesture refs outlive detached dependents until Gesture Handler's passive cleanup, so remounting a related gesture can query an unmounted view. Their tracked native styles and existing themed leaf props update without replacing the gesture hosts or draggable lists. Parsed PR markdown uses its own `withUnistyles` style mapping.
- **Appearance keys belong below native lifetime owners.**`ThemedStack` wraps screen content through `screenLayout`, preserving navigator identity; nested navigators are listed in `nestedNavigatorScreens` and own their own screen boundaries. The remaining shell surfaces retain their appearance refresh separately from native panel hosts. A key above a native stack can detach its screen container inside a FragmentManager transaction during settings hydration. Keep both navigators and retained panel gesture hosts outside those keys. `applyAppearance` patches the active theme before inactive registry entries so subscribers receive the committed values in the same update. Do not add local appearance keys or a boundary above a navigator.
- **Dynamic font tokens stay widened.**`fontFamily`, `fontSize`, and `lineHeight` on `commonTheme` are annotated `string`/`number` (not narrowed by `as const`) so the updater's return assigns; the platform default stacks live in `DEFAULT_UI_FONT_STACK` / `DEFAULT_MONO_FONT_STACK`.
## Patching The Web Runtime
When backporting a Unistyles web fix, patch the TypeScript source and both
shipped JavaScript builds. Native Metro resolves the package's `react-native`
export to `src`, but browser and Electron Metro resolve its `browser` export to
`lib/module`; CommonJS consumers use `lib/commonjs`. A source-only patch leaves
Electron running the old code.
Register every dependency patch in `scripts/postinstall-patches.mjs`. A file in
`patches/` is inert unless that script knows which installed package activates
it.
## Debugging
To inspect what the Babel plugin sees, temporarily enable [`debug: true`](https://www.unistyl.es/v3/other/babel-plugin#debug) in `packages/app/babel.config.js`:
- 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
- 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
- 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
- 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
- Fixed intermittent text clipping in iOS chats and Markdown previews
Full notes: https://paseo.sh/changelog
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.