* 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.
2026-08-09 01:56:32 +02:00
1960 changed files with 177240 additions and 44012 deletions
<!-- 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 provider to Paseo users. -->
<!-- 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. -->
- Added active-turn steering for pi agents so new messages reach the running turn without interrupting it ([#3752](https://github.com/getpaseo/paseo/pull/3752) by [@mcowger](https://github.com/mcowger))
- Added declared build commands and monorepo source paths for Git-hosted plugins ([#4158](https://github.com/getpaseo/paseo/pull/4158))
### Improved
- Reduced JS stalls while watching a streaming agent on mobile ([#4190](https://github.com/getpaseo/paseo/pull/4190))
### Fixed
- Fixed large and many-file diffs stalling or crashing while opening and scrolling ([#4174](https://github.com/getpaseo/paseo/pull/4174))
- Fixed mobile sidebar and explorer panels losing their settled position when a JS stall crossed the animation ([#4190](https://github.com/getpaseo/paseo/pull/4190))
- Fixed a single oversized assistant message crashing the timeline by capping rendered content at 32,000 characters ([#4166](https://github.com/getpaseo/paseo/pull/4166))
- Fixed Codex rewind failing on paginated threads with Codex 0.151 and later ([#4119](https://github.com/getpaseo/paseo/pull/4119))
- Fixed Escape closing an image preview also interrupting the running agent ([#4161](https://github.com/getpaseo/paseo/pull/4161))
- Fixed daemon stop and restart on Windows skipping graceful shutdown and orphaning agent processes ([#4168](https://github.com/getpaseo/paseo/pull/4168))
- Fixed OMP startup timing out at 10 seconds regardless of the configured RPC timeout ([#4143](https://github.com/getpaseo/paseo/pull/4143) by [@Juns-g](https://github.com/Juns-g))
- Fixed completed turns without a visible prompt showing no completion time ([#4170](https://github.com/getpaseo/paseo/pull/4170))
- Fixed Explorer renames replacing an existing file when file identity metadata is unavailable ([#4171](https://github.com/getpaseo/paseo/pull/4171))
## 0.7.1 - 2026-09-01
### Added
- Added Claude Fable 5.1 with a 1M context window ([#4179](https://github.com/getpaseo/paseo/pull/4179))
## 0.7.0 - 2026-08-31
### Changed
- Changed the project license to Apache-2.0 ([#3944](https://github.com/getpaseo/paseo/pull/3944))
- Changed Cmd/Ctrl+E to toggle Explorer visibility without selecting a view ([#3896](https://github.com/getpaseo/paseo/pull/3896))
- Changed the default mobile content text size from 15px to 16px ([#4111](https://github.com/getpaseo/paseo/pull/4111))
### Added
- Added plugin installation and updates directly from Git repositories ([#3920](https://github.com/getpaseo/paseo/pull/3920))
- Added plugin-defined timeline transformations and rendering ([#3940](https://github.com/getpaseo/paseo/pull/3940))
- Added SSH connections to existing remote daemons from Desktop and CLI ([#3989](https://github.com/getpaseo/paseo/pull/3989) by [@reidlevesque](https://github.com/reidlevesque))
- Added workspace management and layout actions to the Command Center ([#3013](https://github.com/getpaseo/paseo/pull/3013) by [@cleiter](https://github.com/cleiter))
- Added contextual composer-pill contributions for plugins ([#3956](https://github.com/getpaseo/paseo/pull/3956))
- Added host UI primitives for client plugins ([#3967](https://github.com/getpaseo/paseo/pull/3967))
- Added readable prompts, details, and results for Paseo tool calls ([#4066](https://github.com/getpaseo/paseo/pull/4066))
- Added zoom and pan to workspace images and assistant timeline previews ([#4032](https://github.com/getpaseo/paseo/pull/4032), [#4049](https://github.com/getpaseo/paseo/pull/4049), [#4064](https://github.com/getpaseo/paseo/pull/4064))
- Added opening child folders directly in the preferred desktop editor ([#3615](https://github.com/getpaseo/paseo/pull/3615) by [@caikovsky](https://github.com/caikovsky))
- Added host navigation from plugin surfaces to agents and workspaces ([#3901](https://github.com/getpaseo/paseo/pull/3901) by [@omercnet](https://github.com/omercnet))
- Added host-rendered icons to plugin client surfaces ([#3903](https://github.com/getpaseo/paseo/pull/3903) by [@omercnet](https://github.com/omercnet))
- Added semantic surface, border, success, and warning colors to plugin themes ([#3898](https://github.com/getpaseo/paseo/pull/3898) by [@omercnet](https://github.com/omercnet))
- Added registered project listing to the public plugin SDK ([#3899](https://github.com/getpaseo/paseo/pull/3899) by [@omercnet](https://github.com/omercnet))
- Added PR and MR number search to the Command Center ([#3008](https://github.com/getpaseo/paseo/pull/3008) by [@cleiter](https://github.com/cleiter))
- Added session commands to agent SDK handles ([#3719](https://github.com/getpaseo/paseo/pull/3719) by [@gpambrozio](https://github.com/gpambrozio), [@marvin-ambrozio](https://github.com/marvin-ambrozio))
- Added F-Droid release metadata and stable changelog generation ([#2501](https://github.com/getpaseo/paseo/pull/2501) by [@antonok-edm](https://github.com/antonok-edm))
- Added Astro syntax highlighting to files, the editor, and diffs ([#3997](https://github.com/getpaseo/paseo/pull/3997))
### Improved
- Streamed assistant text at a steady visual rate instead of in arrival lumps ([#3612](https://github.com/getpaseo/paseo/pull/3612) by [@Tommypop2](https://github.com/Tommypop2))
- Painted cached conversations before unrelated cache restoration and network loading ([#3907](https://github.com/getpaseo/paseo/pull/3907))
- Reused one OpenCode helper across ordinary agent sessions instead of spawning one per agent ([#4009](https://github.com/getpaseo/paseo/pull/4009))
- Batched GitHub pull request polling within the reserved API budget ([#3825](https://github.com/getpaseo/paseo/pull/3825) by [@dezchai](https://github.com/dezchai))
- Added manual, action-required, and warning states to GitLab and Gitea checks ([#2337](https://github.com/getpaseo/paseo/pull/2337) by [@nllptrx](https://github.com/nllptrx))
- Kept cold-start pull request status checks on the shared GitHub batch path ([#4025](https://github.com/getpaseo/paseo/pull/4025))
### Fixed
- Fixed the daemon crashing when a Claude or OMP JSONL process closes during a write ([#4048](https://github.com/getpaseo/paseo/pull/4048))
- Fixed Android first-message submissions crashing when the keyboard restarted during composer teardown ([#4044](https://github.com/getpaseo/paseo/pull/4044))
- Fixed long mobile drafts hiding composer controls behind the keyboard ([#4051](https://github.com/getpaseo/paseo/pull/4051))
- Fixed Android timelines shifting when tapping selectable text ([#4090](https://github.com/getpaseo/paseo/pull/4090))
- Fixed web and Electron composers losing focus after submission ([#4067](https://github.com/getpaseo/paseo/pull/4067))
- Fixed Android dictation leaving Bluetooth audio in call-quality routing after capture stopped ([#4069](https://github.com/getpaseo/paseo/pull/4069))
- Fixed dictation retries timing out after an abandoned partial transcript ([#4065](https://github.com/getpaseo/paseo/pull/4065))
- Fixed Android system navigation covering the Commits controls ([#4005](https://github.com/getpaseo/paseo/pull/4005))
- Fixed new agent work appearing as reopened tasks ([#4068](https://github.com/getpaseo/paseo/pull/4068))
- Fixed Markdown literal characters being replaced by typographic symbols ([#3253](https://github.com/getpaseo/paseo/pull/3253) by [@cleiter](https://github.com/cleiter))
- Fixed rewinding long conversations replaying the complete rebuilt timeline ([#3642](https://github.com/getpaseo/paseo/pull/3642))
- Fixed rewound prompts remaining blank in the mounted iOS composer ([#3946](https://github.com/getpaseo/paseo/pull/3946))
- Fixed native plugin setup and async client callbacks failing on iOS ([#3942](https://github.com/getpaseo/paseo/pull/3942))
- Fixed ACP agents reporting the terminal capability as unavailable ([#3910](https://github.com/getpaseo/paseo/pull/3910) by [@pmilanez](https://github.com/pmilanez))
- Fixed canceled Claude permission requests leaving stale permission cards ([#3792](https://github.com/getpaseo/paseo/pull/3792))
- Fixed plugin daemon sessions hiding agents from non-legacy providers ([#3902](https://github.com/getpaseo/paseo/pull/3902) by [@omercnet](https://github.com/omercnet))
- Fixed mobile Changes and pull-request actions opening outside Explorer ([#3867](https://github.com/getpaseo/paseo/pull/3867))
- Fixed older desktop builds deleting settings written by newer builds ([#3909](https://github.com/getpaseo/paseo/pull/3909) by [@cleiter](https://github.com/cleiter))
- Fixed composer height flashing after returning from Settings ([#3943](https://github.com/getpaseo/paseo/pull/3943))
- Fixed selected workspaces having no visible highlight in the Light theme ([#3922](https://github.com/getpaseo/paseo/pull/3922) by [@wdaubney](https://github.com/wdaubney))
- Fixed the scrolling diff order disagreeing with the Changes tree ([#3913](https://github.com/getpaseo/paseo/pull/3913) by [@cleiter](https://github.com/cleiter))
- Fixed incorrect Spanish translations for Close actions ([#3934](https://github.com/getpaseo/paseo/pull/3934) by [@antonio](https://github.com/antonio))
## 0.6.1 - 2026-08-25
### Improved
- Command Center now matches partial multi-word queries in any order and ranks visible results by relevance ([#2971](https://github.com/getpaseo/paseo/pull/2971) by [@cleiter](https://github.com/cleiter))
### Fixed
- Fixed upgrades placing persisted Side panel tabs inside Explorer instead of an ordinary side pane ([#3861](https://github.com/getpaseo/paseo/pull/3861))
- Fixed Escape leaving Settings open on desktop and web ([#2828](https://github.com/getpaseo/paseo/pull/2828) by [@mcowger](https://github.com/mcowger))
- Fixed branch switches hiding the current pull request or arming auto-archive from an already-merged pull request ([#3799](https://github.com/getpaseo/paseo/pull/3799))
## 0.6.0 - 2026-08-25
### Changed
- Restored the dedicated Explorer sidebar with Files and Changes by default, replacing the empty user-directed Side panel introduced in 0.5.0 ([#3826](https://github.com/getpaseo/paseo/pull/3826))
- Changed content opened to the side to use an ordinary closable workspace pane instead of the Explorer sidebar ([#3826](https://github.com/getpaseo/paseo/pull/3826))
- Restored the resizable combined Explorer dock on tablets ([#3826](https://github.com/getpaseo/paseo/pull/3826))
### Added
- Added per-action settings for opening files, changes, subagents, and pull requests in the main panel or on the side ([#3826](https://github.com/getpaseo/paseo/pull/3826))
- Added New tab and drag support for compatible files, diffs, agents, terminals, pull requests, and plugin panels in Explorer ([#3826](https://github.com/getpaseo/paseo/pull/3826))
- Added standalone Diff tabs while keeping an optional persisted inline-diff mode in Changes ([#3826](https://github.com/getpaseo/paseo/pull/3826))
- Added custom Windows and Linux window controls ([#3826](https://github.com/getpaseo/paseo/pull/3826))
### Fixed
- Fixed OpenCode turns failing when the first event-stream connection stalls during startup ([#3814](https://github.com/getpaseo/paseo/pull/3814))
- Fixed OpenCode prompts starting before the provider reports a ready connection ([#3821](https://github.com/getpaseo/paseo/pull/3821))
## 0.5.2 - 2026-08-24
### Fixed
- Fixed workspaces crashing on Android tablets and foldables in non-compact layouts ([#3760](https://github.com/getpaseo/paseo/pull/3760) by [@jeid64](https://github.com/jeid64))
- Fixed app settings resetting when builds sharing a profile recognize different settings ([#3787](https://github.com/getpaseo/paseo/pull/3787))
## 0.5.1 - 2026-08-23
### Fixed
- Fixed multiline composers stopping resizing after workspace and agent transitions on iOS and Android ([#3740](https://github.com/getpaseo/paseo/pull/3740))
## 0.5.0 - 2026-08-23
### Added
- Added experimental trusted local plugins ([#3222](https://github.com/getpaseo/paseo/pull/3222), [#3446](https://github.com/getpaseo/paseo/pull/3446), [#3465](https://github.com/getpaseo/paseo/pull/3465))
- Added plugin-contributed application themes to Settings → Appearance ([#3602](https://github.com/getpaseo/paseo/pull/3602) by [@ragokan](https://github.com/ragokan))
- Added a user-directed Side panel with independent tabs ([#3287](https://github.com/getpaseo/paseo/pull/3287), [#3605](https://github.com/getpaseo/paseo/pull/3605))
- Added browser-style New tabs with an anchored pane-local chooser ([#3715](https://github.com/getpaseo/paseo/pull/3715), [#3735](https://github.com/getpaseo/paseo/pull/3735))
- Added active-turn steering for Claude, Codex, and OpenCode ([#3394](https://github.com/getpaseo/paseo/pull/3394), [#3580](https://github.com/getpaseo/paseo/pull/3580) by [@mcowger](https://github.com/mcowger))
- Added workspace labels for sidebar organization and filtering ([#3510](https://github.com/getpaseo/paseo/pull/3510) by [@cleiter](https://github.com/cleiter))
- Added live workspace change counts above active agent composers ([#3682](https://github.com/getpaseo/paseo/pull/3682))
- Added workspace panel, tab, and pane actions to the Command Center ([#3685](https://github.com/getpaseo/paseo/pull/3685))
- Added `paseo project` to create, list, rename, and delete projects from the terminal ([#3460](https://github.com/getpaseo/paseo/pull/3460))
- Added `paseo reload` to apply runtime-safe `config.json` changes without restarting the daemon ([#3365](https://github.com/getpaseo/paseo/pull/3365))
- Added Paseo skill management to Host → Agents, including remote hosts ([#3451](https://github.com/getpaseo/paseo/pull/3451))
- Added project filtering to the sidebar display menu ([#3563](https://github.com/getpaseo/paseo/pull/3563) by [@cleiter](https://github.com/cleiter))
- Added optional branch and project names on workspace rows ([#3445](https://github.com/getpaseo/paseo/pull/3445))
- Added drag-and-drop reordering for pinned workspaces ([#3341](https://github.com/getpaseo/paseo/pull/3341))
- Added one-click agent profile creation from the model chooser ([#3533](https://github.com/getpaseo/paseo/pull/3533))
- Added Android Studio to Open in editor ([#3531](https://github.com/getpaseo/paseo/pull/3531), [#3614](https://github.com/getpaseo/paseo/pull/3614) by [@caikovsky](https://github.com/caikovsky))
- Added MiniMax Code to the one-click ACP provider catalog ([#3457](https://github.com/getpaseo/paseo/pull/3457) by [@hetaoBackend](https://github.com/hetaoBackend))
- Added Nix syntax highlighting ([#3110](https://github.com/getpaseo/paseo/pull/3110) by [@Strainy](https://github.com/Strainy))
- Added Svelte syntax highlighting ([#3487](https://github.com/getpaseo/paseo/pull/3487), [#3534](https://github.com/getpaseo/paseo/pull/3534) by [@fiorelorenzo](https://github.com/fiorelorenzo))
- Added guided Hub setup that connects a daemon and deploys a compatible starter workflow ([#3651](https://github.com/getpaseo/paseo/pull/3651), [#3657](https://github.com/getpaseo/paseo/pull/3657), [#3677](https://github.com/getpaseo/paseo/pull/3677))
### Improved
- Restored cached projects, workspaces, agents, and timelines immediately while hosts reconnect ([#3259](https://github.com/getpaseo/paseo/pull/3259), [#3329](https://github.com/getpaseo/paseo/pull/3329))
- Reduced workspace-switch stalls on desktop and Android with long chats ([#3447](https://github.com/getpaseo/paseo/pull/3447), [#3610](https://github.com/getpaseo/paseo/pull/3610))
- Kept composer typing within the frame budget on web and desktop ([#3450](https://github.com/getpaseo/paseo/pull/3450))
- Kept large diffs responsive while expanding, scrolling, and commenting ([#3422](https://github.com/getpaseo/paseo/pull/3422))
- Kept large read-only source previews responsive with bounded rendering ([#3665](https://github.com/getpaseo/paseo/pull/3665))
- Removed complete-transcript disk rewrites from long-running agent timelines ([#3647](https://github.com/getpaseo/paseo/pull/3647))
- Added outcome summaries and failure-first grouping to pull request checks ([#3483](https://github.com/getpaseo/paseo/pull/3483))
- Moved subagent and task trackers into pills above the composer ([#3482](https://github.com/getpaseo/paseo/pull/3482))
- Simplified mobile agent configuration into one options sheet ([#3424](https://github.com/getpaseo/paseo/pull/3424))
- Opened summarized tool-call groups in live sheets on compact layouts ([#3619](https://github.com/getpaseo/paseo/pull/3619))
- Added separate Content text sizing for chat, the composer, Markdown, and review prose ([#3637](https://github.com/getpaseo/paseo/pull/3637))
- Updated Pi usage and context meters during active turns ([#3532](https://github.com/getpaseo/paseo/pull/3532))
- Changed Archive finished to archive every finished subagent in the track ([#3368](https://github.com/getpaseo/paseo/pull/3368))
- Changed automatic Setup tabs to appear only after workspace setup fails ([#3634](https://github.com/getpaseo/paseo/pull/3634))
- Changed Default send in Settings to a dropdown menu ([#3644](https://github.com/getpaseo/paseo/pull/3644))
- Added password-equivalent warnings to pairing links in the desktop app and CLI ([#3734](https://github.com/getpaseo/paseo/pull/3734))
- Corrected Russian UI translations and terminology ([#3586](https://github.com/getpaseo/paseo/pull/3586) by [@timz](https://github.com/timz))
### Fixed
- Fixed CJK IME composition being cancelled in text fields and mobile terminals ([#2811](https://github.com/getpaseo/paseo/pull/2811), [#3343](https://github.com/getpaseo/paseo/pull/3343), [#3391](https://github.com/getpaseo/paseo/pull/3391), [#3462](https://github.com/getpaseo/paseo/pull/3462), [#3517](https://github.com/getpaseo/paseo/pull/3517) by [@northsea4](https://github.com/northsea4), [@jimersylee](https://github.com/jimersylee), [@ZacharyZcR](https://github.com/ZacharyZcR), [@chulmin-dev](https://github.com/chulmin-dev))
- Fixed OpenCode turns failing when their event stream dropped ([#3395](https://github.com/getpaseo/paseo/pull/3395))
- Fixed OpenCode agents timing out during slow or plugin-heavy startup ([#3578](https://github.com/getpaseo/paseo/pull/3578), [#3621](https://github.com/getpaseo/paseo/pull/3621) by [@BrianAguilarWasco](https://github.com/BrianAguilarWasco))
- Fixed an unrelated merged pull request archiving a workspace ([#3425](https://github.com/getpaseo/paseo/pull/3425))
- Fixed Annotate element and Screenshot element doing nothing on loaded desktop browser pages ([#3187](https://github.com/getpaseo/paseo/pull/3187) by [@dgk-dev](https://github.com/dgk-dev))
- Fixed repeated copy and fork footers after heartbeat runs ([#3484](https://github.com/getpaseo/paseo/pull/3484))
- Fixed Cursor plan usage on hosts signed in only through `cursor-agent` ([#3486](https://github.com/getpaseo/paseo/pull/3486) by [@Lite-G](https://github.com/Lite-G))
- Fixed mobile composers retaining sent text after submission ([#3564](https://github.com/getpaseo/paseo/pull/3564))
- Fixed multiline composers retaining stale heights across workspace and draft lifecycles ([#3681](https://github.com/getpaseo/paseo/pull/3681), [#3740](https://github.com/getpaseo/paseo/pull/3740))
- Fixed pull request checkout when the target repository is configured as `upstream` ([#2997](https://github.com/getpaseo/paseo/pull/2997) by [@mcowger](https://github.com/mcowger))
- Fixed composer steers remaining unread while Claude or Codex waited on a permission ([#3585](https://github.com/getpaseo/paseo/pull/3585) by [@cleiter](https://github.com/cleiter))
- Fixed Changes opening an empty comparison after checkout state changes ([#3636](https://github.com/getpaseo/paseo/pull/3636))
- Fixed Pi chats ending in an error state during a successful automatic retry ([#3639](https://github.com/getpaseo/paseo/pull/3639))
- Fixed Claude usage appearing unavailable when macOS Keychain contains multiple credential items ([#3597](https://github.com/getpaseo/paseo/pull/3597) by [@t-benoit](https://github.com/t-benoit))
- Fixed provider usage checks overwriting Claude or Codex credential files ([#3442](https://github.com/getpaseo/paseo/pull/3442) by [@danberindei](https://github.com/danberindei))
- Fixed active Codex agents failing to open when their native thread was archived outside Paseo ([#3334](https://github.com/getpaseo/paseo/pull/3334))
- Fixed agents remaining impossible to stop after the provider had already settled their turn ([#3742](https://github.com/getpaseo/paseo/pull/3742))
- Fixed project icons showing stale images in New Workspace ([#3600](https://github.com/getpaseo/paseo/pull/3600))
- Fixed malformed SVG project icons crashing the iOS app ([#3711](https://github.com/getpaseo/paseo/pull/3711) by [@omercnet](https://github.com/omercnet))
- Fixed pinning a project's only workspace hiding its New workspace action ([#3722](https://github.com/getpaseo/paseo/pull/3722) by [@yugui923](https://github.com/yugui923))
- Fixed fresh installs resolving provider SDK versions that were not validated with the release ([#3678](https://github.com/getpaseo/paseo/pull/3678))
## 0.4.0 - 2026-08-13
### Breaking
- Removed `paseo chat` and `paseo loop` ([#3053](https://github.com/getpaseo/paseo/pull/3053))
### Added
- Added host-wide agent profiles for reusable provider, model, mode, thinking, and feature settings ([#3208](https://github.com/getpaseo/paseo/pull/3208))
- Added workspace file search to Cmd/Ctrl+P ([#3059](https://github.com/getpaseo/paseo/pull/3059))
- Added file and folder actions to Files and Changes ([#3027](https://github.com/getpaseo/paseo/pull/3027) by [@nikuscs](https://github.com/nikuscs))
- Added interactive Mermaid diagrams to chats and Markdown previews ([#2306](https://github.com/getpaseo/paseo/pull/2306) by [@dmeledon](https://github.com/dmeledon))
- Added live task progress above the composer and in the timeline ([#3227](https://github.com/getpaseo/paseo/pull/3227))
- Added a model setting for generated workspace titles, branch names, commits, and pull request drafts ([#3215](https://github.com/getpaseo/paseo/pull/3215))
- Added the supported `@getpaseo/client` TypeScript SDK ([#3141](https://github.com/getpaseo/paseo/pull/3141))
- Added provider installation diagnostics to the CLI ([#3243](https://github.com/getpaseo/paseo/pull/3243))
- Added workspace rename to the CLI ([#3209](https://github.com/getpaseo/paseo/pull/3209) by [@martinhanzik](https://github.com/martinhanzik))
### Improved
- Added a centered reading layout and formatted YAML front matter to Markdown previews ([#3240](https://github.com/getpaseo/paseo/pull/3240))
- Added sidebar grouping to the Command Center ([#3063](https://github.com/getpaseo/paseo/pull/3063) by [@cleiter](https://github.com/cleiter))
- Added every built-in theme to theme shortcuts ([#3214](https://github.com/getpaseo/paseo/pull/3214))
- Removed periodic Git polling for idle workspaces ([#3323](https://github.com/getpaseo/paseo/pull/3323))
- Set provider catalog refreshes to a configurable two-minute deadline ([#3322](https://github.com/getpaseo/paseo/pull/3322))
### Fixed
- Fixed terminal sessions being lost after host sleep or daemon worker stalls ([#3235](https://github.com/getpaseo/paseo/pull/3235), [#3263](https://github.com/getpaseo/paseo/pull/3263))
- Fixed daemon hang when archiving a workspace ([#3107](https://github.com/getpaseo/paseo/pull/3107))
- Fixed New Workspace crash on Android when projects span multiple hosts ([#3241](https://github.com/getpaseo/paseo/pull/3241))
- Fixed provider cache exhausting Android local storage ([#3234](https://github.com/getpaseo/paseo/pull/3234))
- Fixed dictation recordings being lost during connection interruptions ([#3159](https://github.com/getpaseo/paseo/pull/3159))
- Fixed new workspaces reusing an existing worktree ([#3224](https://github.com/getpaseo/paseo/pull/3224))
- Fixed new worktrees ignoring the setup command saved in Project Settings ([#3233](https://github.com/getpaseo/paseo/pull/3233))
- Fixed new worktrees becoming dirty when the selected base contained a different `paseo.json` ([#3311](https://github.com/getpaseo/paseo/pull/3311))
- Fixed removed hosts continuing to receive push notifications ([#3176](https://github.com/getpaseo/paseo/pull/3176))
- Fixed delegated-agent notifications stopping after permission prompts or workspace closure ([#3177](https://github.com/getpaseo/paseo/pull/3177), [#3192](https://github.com/getpaseo/paseo/pull/3192) by [@thomasvan](https://github.com/thomasvan), [@wilgon456](https://github.com/wilgon456))
- Fixed OMP agent startup when model catalogs exceed the protocol-v1 frame limit ([#3184](https://github.com/getpaseo/paseo/pull/3184) by [@pi3123](https://github.com/pi3123))
- Fixed OpenCode models being unable to return to their default variant ([#3281](https://github.com/getpaseo/paseo/pull/3281))
- Fixed completed Codex subagents remaining marked active ([#3188](https://github.com/getpaseo/paseo/pull/3188) by [@Strainy](https://github.com/Strainy))
- Fixed Codex compaction leaving turns marked as working ([#3211](https://github.com/getpaseo/paseo/pull/3211) by [@edihasaj](https://github.com/edihasaj))
- Fixed Cursor usage with current Cursor logins ([#2704](https://github.com/getpaseo/paseo/pull/2704) by [@QuteSaltyFish](https://github.com/QuteSaltyFish))
- Fixed SVG project icons rendering as blank on mobile ([#2579](https://github.com/getpaseo/paseo/pull/2579) by [@colonelpanic8](https://github.com/colonelpanic8))
- Fixed crash when persisted cache was incompatible ([#3289](https://github.com/getpaseo/paseo/pull/3289))
- Fixed open delegated agents being archived with their parent ([#3279](https://github.com/getpaseo/paseo/pull/3279))
- Fixed agent creation when profiles carried modes unsupported by the selected provider ([#3331](https://github.com/getpaseo/paseo/pull/3331))
- Fixed Claude agents waiting on Paseo's ten-minute MCP timeout ([#3315](https://github.com/getpaseo/paseo/pull/3315))
- Fixed Copy resume command for Hermes agents ([#3300](https://github.com/getpaseo/paseo/pull/3300) by [@desmond-rai](https://github.com/desmond-rai))
## 0.3.1 - 2026-08-09
### Added
- Unassign keyboard shortcuts and see the effective bindings in shortcut help ([#2510](https://github.com/getpaseo/paseo/pull/2510) by [@cleiter](https://github.com/cleiter), [#2985](https://github.com/getpaseo/paseo/pull/2985) by [@cleiter](https://github.com/cleiter))
- Choose a Pure black theme ([#3012](https://github.com/getpaseo/paseo/pull/3012) by [@shrimpwtf](https://github.com/shrimpwtf))
### Improved
- Running agents use a clearer animated status ring ([c0daf8e](https://github.com/getpaseo/paseo/commit/c0daf8e066e0d7379723a2a6ddea20afc71ead20) by [@cleiter](https://github.com/cleiter))
- Workspace Git updates stay responsive under file-watcher failures, heavy Git activity, and large shared-worktree repositories ([#3056](https://github.com/getpaseo/paseo/pull/3056), [#3033](https://github.com/getpaseo/paseo/pull/3033) by [@dwyanewang](https://github.com/dwyanewang), [#3078](https://github.com/getpaseo/paseo/pull/3078))
- Kimi usage refreshes correctly and shows five-hour limits and reasoning levels ([#2743](https://github.com/getpaseo/paseo/pull/2743) by [@UnbrokenHunter](https://github.com/UnbrokenHunter))
- Resize the desktop sidebar with touch ([#2953](https://github.com/getpaseo/paseo/pull/2953) by [@colonelpanic8](https://github.com/colonelpanic8))
### Fixed
- Direct LAN connections work again on macOS ([#3071](https://github.com/getpaseo/paseo/pull/3071))
- Desktop update checks no longer report an error while platform downloads are still publishing ([#3076](https://github.com/getpaseo/paseo/pull/3076))
- Copied lists keep their markers across selection boundaries ([#3055](https://github.com/getpaseo/paseo/pull/3055))
- Switching hosts keeps the active project selected ([#3051](https://github.com/getpaseo/paseo/pull/3051))
- New Workspace keeps project and isolation controls inside the composer ([#3050](https://github.com/getpaseo/paseo/pull/3050))
- Session rename is reachable from the mobile sessions menu ([#2924](https://github.com/getpaseo/paseo/pull/2924) by [@kaspesi](https://github.com/kaspesi))
- Desktop notification sounds play again ([#2582](https://github.com/getpaseo/paseo/pull/2582) by [@gstamp](https://github.com/gstamp))
- Codex slash commands omit disabled skills ([#2759](https://github.com/getpaseo/paseo/pull/2759) by [@jasonhnd](https://github.com/jasonhnd))
- Exact ignored file paths resolve correctly ([#3042](https://github.com/getpaseo/paseo/pull/3042) by [@cleiter](https://github.com/cleiter))
- Claude question notifications summarize the requested input ([#2925](https://github.com/getpaseo/paseo/pull/2925) by [@SihyeonJeon](https://github.com/SihyeonJeon))
- The crash screen stays readable and keeps its retry action reachable ([#3043](https://github.com/getpaseo/paseo/pull/3043))
| [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 |
| [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 |
@@ -6,7 +6,7 @@ Thank you for taking the time to contribute to Paseo.
Paseo is an opinionated product, built on freedom and flexibility: any agent provider, any device, running on your own machine. It is meant to be composable, so you can build the workflow you want. Read more about the product vision [here](docs/product.md).
Given Paseo's scope, contributing to to it takes a lot of context that is very hard to transfer. That's why product, design, architecture, and workflow decisions are currently all made by the maintainer.
Given Paseo's scope, contributing to it takes a lot of context that is very hard to transfer. That's why product, design, architecture, and workflow decisions are currently all made by the maintainer.
I pick what to build based on whether it fits the product, how many workflows it improves, whether it keeps things composable, whether we can hold the quality bar, and whether I want to build it.
@@ -72,8 +72,8 @@ Here is the criteria I use to decide:
- PRs that were explicitly approved in a discussion are preferred.
- Unsolicited PR can be closed without a detailed review.
- Your PR can be narrowed, refactored, or redesigned.
- Your PR might be accpeted but not merged immediately.
- You wil be attributed for your work in the changelog, even if I redesign your PR
- Your PR might be accepted but not merged immediately.
- You will be attributed for your work in the changelog, even if I redesign your PR
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.
@@ -128,7 +152,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.
@@ -165,9 +188,8 @@ npm run typecheck
## Related projects
- [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — official distributed relay, written in Elixir
- [paseo-skins](https://github.com/huangguang1999/paseo-skins) — community themes and a zero-patch desktop theme loader with an Agent Skill
- [paseo-vscode](https://marketplace.visualstudio.com/items?itemName=hinnes.paseo-vscode) — VS Code extension
Cancellation changes lifecycle state only after the provider acknowledges the interrupt or emits a terminal turn event. If the interrupt is rejected or times out, the agent remains `running` with its active foreground turn intact. Follow-up actions such as replacement, reload, rewind, and Stop must report that failure instead of accepting work they cannot perform. Synthesizing a local cancellation without provider acknowledgment creates a split-brain session: Paseo accepts a new prompt while the provider still owns the previous foreground turn.
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
@@ -44,11 +53,26 @@ Agents can launch other agents via the agent-scoped `create_agent` MCP tool. Age
- **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.
Parent archive detaches a subagent instead of archiving it when either condition holds:
- 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 only selects an existing workspace.
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 `paseo.parent-agent-id` label only: 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.
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 background 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
@@ -71,7 +95,7 @@ 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.
@@ -88,7 +112,9 @@ recoverable from History. Opening one pins its tab and renders the archived-agen
timeline catch-up may load provider history with a runtime-only `history` resume purpose, which must
leave both Paseo's `archivedAt` and the provider's native archive state unchanged. **Unarchive** remains
the only transition back to an interactive runtime: it runs the provider's native unarchive hook
(including Codex `thread/unarchive`) before the normal agent resume and timeline hydration flow.
(including Codex `thread/unarchive`) before the normal agent resume and timeline hydration flow. A
provider session can be archived outside Paseo while its Paseo agent remains active. Interactive
resume repairs that drift through the provider's native unarchive hook; history resume does not.
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,
@@ -106,7 +132,7 @@ 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 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.
@@ -120,7 +146,9 @@ Running provider-native subagents contribute `running` to the workspace owned by
## The subagents track
The collapsible track above the composer in an agent's pane (`packages/app/src/subagents/track.tsx`) combines two kinds of children:
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:
@@ -143,6 +171,7 @@ Claude Code announces subagent lifecycle on the SDK stream (`task_started` / `ta
- **Not every announced task belongs in the track.** Task subagents announce as `local_agent` and workflows as `local_workflow`; a backgrounded shell announces as `local_bash` with the same `tool_use_id` shape, and ambient housekeeping sets `skip_transcript`. The Claude provider normalizes a workflow to a generic provider-subagent descriptor titled `Workflow`, using Claude's summary as its description and timeline opener. Shared storage, protocol, and UI do not distinguish it from another provider subagent.
- **A task that was never declared gets no descriptor, by any route.** Filtered tasks still emit `task_notification`s carrying a `tool_use_id`, and still emit frames carrying `parent_tool_use_id`. Attributing either produces a descriptor with no identity and a defaulted `running` status — a nameless row that never finishes. Status, presentation updates, and sidechain frames all route through the declaration table.
- **Task ids are session-scoped, not turn-scoped.** Cancelling a turn must not clear the routing table: a backgrounded child settles after the interrupt and needs its descriptor to still exist. Cancellation instead terminalizes the declared children that were running in the foreground, and a later `task_notification` is free to correct that guess. Backgrounded children are identified by `task_updated.patch.is_backgrounded`.
- **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.
- **On replay, `<session>/subagents/` holds every descendant, not just this session's children.** `agent-<id>.meta.json` carries `spawnDepth`: `1` is a direct child, `2+` was spawned by another subagent and its `toolUseId` names a Task call made inside its parent's session, which nothing in this transcript can resolve. Replaying those adds rows the live stream never showed, each with no Task card and no recoverable outcome, so they render as running forever. One recorded session showed 10 subagents live and would have replayed 22.
@@ -150,9 +179,9 @@ Claude Code announces subagent lifecycle on the SDK stream (`task_started` / `ta
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 track header's **Archive finished**action hides finished provider-owned rows in the current app session. Their native sessions and timelines are untouched, and managed Paseo subagents are not archived by this bulk action. If a hidden provider child starts running again, the app brings it back to the track.
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 parent label, emits the normal agent update, and every client reclassifies the agent from subagent to root/sibling from that updated snapshot.
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
@@ -170,7 +199,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. Managed Paseo subagents can be archived individually. Finished provider-owned rows can be hidden together with **Archive finished**; this is app-local presentation state and resets when the app restarts.
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.
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.
- **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.
@@ -15,7 +15,9 @@ EAS profiles: `development`, `production`, and `production-apk` in `packages/app
## Version codes
`packages/app/app.config.js` derives Android `versionCode` from the package version with:
`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:
@@ -145,6 +147,37 @@ Keep the excluded npm packages installed. Normal builds use them, while the F-Dr
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
@@ -93,25 +94,50 @@ 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, 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.
### `packages/app` — Mobile + web client (Expo)
Cross-platform React Native app that connects to one or more daemons.
- 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
-`runtime/replica-cache`keeps a non-authoritative per-host display replica in AsyncStorage: only the last focused agent, its workspace, and a short timeline tail. It restores before navigation becomes ready, leaves remote hydration flags false, and is atomically replaced by the normal snapshot-plus-delta synchronization 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)
The replica cache exists only to paint stale data immediately while the host connects. It does not
own mutations, infer deletions, or replace daemon reconciliation. Pending permission requests are
not restored from it. AsyncStorage is not encrypted, so the cached timeline tail may contain source
code, prompts, and tool output; encrypted-at-rest storage is a separate product/security decision.
Its serialized payload has a 1 MiB byte budget and evicts whole host snapshots in least-recently-
written order; a single oversized host is omitted rather than partially restored.
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
@@ -119,13 +145,12 @@ Commander.js CLI with Docker-style commands. Common agent operations are also ex
- 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
@@ -162,6 +187,10 @@ 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.
@@ -305,7 +334,7 @@ initializing → idle ⇄ running
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). That storage path is derived from `cwd`, not from workspace id.
- 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
@@ -380,13 +409,11 @@ Providers that can accept native tool definitions should set `supportsNativePase
```
$PASEO_HOME/
├── 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
├── projects/icons/ # Custom project icon images
├── chat/ # Chat rooms
├── schedules/ # Scheduled-agent definitions and runs
├── loops/ # Loop runs and logs
├── config.json # Daemon config (mutable)
├── daemon-keypair.json # Daemon identity for relay/E2EE
@@ -98,6 +98,14 @@ For testing rules, see [testing.md](testing.md).
- 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,20 @@ 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.
---
## Table of Contents
@@ -245,6 +259,8 @@ requires_openai_auth = false
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
@@ -377,6 +393,7 @@ Custom OMP profiles should extend `omp`. They inherit the OMP adapter's `rpc-ui`
@@ -387,7 +404,7 @@ Custom OMP profiles should extend `omp`. They inherit the OMP adapter's `rpc-ui`
}
```
`params.sessionDir` is used only for importing sessions that were started outside Paseo. If `command` or XDG env vars move OMP's state directory, set `params.sessionDir` to the resulting OMP JSONL session directory; launching and resuming still go through the configured command.
`params.sessionDir` is used only for importing sessions that were started outside Paseo. If `command` or XDG env vars move OMP's state directory, set `params.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. `params.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:
@@ -400,7 +417,8 @@ For other providers that keep Pi's `--mode rpc` API but write sessions somewhere
"label":"My Pi Fork",
"command":["my-pi-fork"],
"params":{
"sessionDir":"~/.my-pi-fork/sessions"
"sessionDir":"~/.my-pi-fork/sessions",
"rpcTimeoutMs":60000
}
}
}
@@ -408,7 +426,7 @@ For other providers that keep Pi's `--mode rpc` API but write sessions somewhere
}
```
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>`.
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>`.`params.rpcTimeoutMs` overrides the 60-second Pi control-plane RPC deadline.
---
@@ -485,25 +503,25 @@ Paseo tools such as subagent creation come from the shared internal tool catalog
}
```
ACP agents execute filesystem and terminal operations in their own environment
by default. To let a compliant agent delegate those operations to Paseo instead,
enable the corresponding client capabilities:
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 params:
```json
{
"agents":{
"providers":{
"local-agent":{
"container-agent":{
"extends":"acp",
"label":"Local Agent",
"command":["local-agent","acp"],
"label":"Container Agent",
"command":["container-agent","acp"],
"params":{
"clientCapabilities":{
"fs":{
"readTextFile":true,
"writeTextFile":true
"readTextFile":false,
"writeTextFile":false
},
"terminal":true
"terminal":false
}
}
}
@@ -512,9 +530,11 @@ enable the corresponding client capabilities:
}
```
Only enable capabilities Paseo should execute. When the agent and Paseo run in
different environments, configure equivalent absolute workspace paths before
delegating filesystem or terminal operations to Paseo.
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
@@ -89,7 +90,7 @@ Each agent is stored as a separate JSON file, grouped by project directory.
| `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`is set automatically for agent-scoped creation and removed by detach — see [agent-lifecycle.md](./agent-lifecycle.md) |
| `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 |
| `PASEO_GIT_CONCURRENCY` | Legacy concurrency alias |
`PASEO_GIT_MAX_PROCESS_CONCURRENCY` wins when it and the legacy alias are both set. Restart the
daemon after changing the file or environment. Run `paseo daemon restart` for a standalone daemon.
For a desktop-managed daemon, fully quit and reopen Paseo Desktop.
`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.
@@ -358,133 +398,7 @@ One file per schedule. ID is 8 hex characters.
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.
| `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" |
> **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
@@ -553,7 +492,7 @@ than treating it as valid.
---
## 8. Push Token Store
## 6. Push Token Store
**Path:**`$PASEO_HOME/push-tokens.json`
@@ -567,7 +506,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.
@@ -591,6 +530,26 @@ Right-sidebar client state splits on whether it is determined by the directory o
- **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. Timeline and checkpoint use the singleton id
and have at most one row per host.
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.
@@ -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`).
@@ -60,11 +62,15 @@ 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.xs` labels, `sm` = 32px with `fontSize.sm`, `md`/`lg` = 44px with `fontSize.sm`. `<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 horizontal padding. 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.
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`.
@@ -153,6 +159,8 @@ On a narrow desktop route, app navigation yields to the rendered content topolog
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.
---
@@ -223,15 +231,15 @@ Selected state on rows in a desktop list+detail uses `surfaceSidebarHover` as th
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** 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: pushed away from the surface rather than toward it (darker in light, lighter in dark) and at 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 lightness the dots read dimmer than the metadata beside them — backwards, since the dot is the row's state. All four move together; regenerate the set, never one hue.
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.
Status pills are the status token as foreground on a 10%-alpha tint of the same token (`${token}1a`), with a 20% border (`${token}33`). The `<StatusBadge>` primitive (`packages/app/src/components/ui/status-badge.tsx`) is canonical; a pill never reaches into `palette`.
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.
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>`.
New status pills use `<StatusBadge>`. Identity, shortcut, and interactive link badges remain separate because color does not encode status there.
---
@@ -242,7 +250,7 @@ The bespoke pills in `packages/app/src/screens/settings/host-page.tsx:97-116`, `
- Bare `<Text>` for a section header inside settings. `<SettingsSection>` exists.
- 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 and the identity color table are the documented exceptions (§13), 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`.
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 workspace create/ls/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.
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
@@ -468,12 +556,15 @@ 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 `--host` to point the CLI at a different daemon:
```bash
npm run cli -- --host localhost:7777 ls -a
npm run cli -- ls -a --host localhost:7777
npm run cli -- ls -a --host ssh://user@host
```
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
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. 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.
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. 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 and watcher recovery, never from a healthy-watcher timer.
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.
@@ -19,19 +19,23 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
- **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.
- **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).
- **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.
- **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`).
- **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) or Loop (iterative re-execution of one agent).
- **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** — 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`).
@@ -40,7 +44,8 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
- **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).
@@ -4,7 +4,7 @@ Paseo Hub is an explicit opt-in connection from one Paseo daemon to one Hub. Run
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`. 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.
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. `paseo hub export [directory]` writes the active organization's current triggers as one self-contained YAML file per trigger, 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
@@ -17,17 +17,21 @@ The daemon persists a relationship ID and private connection credential before e
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 run the `hub.management.daemon.connect`,
`hub.management.daemon.get_status`, and `hub.management.daemon.disconnect` RPCs. Hub connections
receive only `hub.execution.*` authority, so execution credentials cannot manage the relationship.
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 execution ownership
Trusted clients and the Hub use the same `Session` implementation. The connection boundary supplies
grants: trusted clients receive `*`, while an enrolled Hub connection receives its persisted
`hub.execution.*` grant. One matcher handles exact RPC names and trailing namespace wildcards for
both inbound requests and outbound messages. A denied request returns the ordinary `rpc_error`
shape.
semantic permissions: trusted clients receive the owner permission set, while an enrolled Hub
connection receives its persisted permissions. Legacy relationship files carrying
`hub.execution.*` migrate once to `hub.execute` at the persistence boundary. A denied request returns
the ordinary `rpc_error` shape. See [permissions.md](permissions.md).
The Hub connection still has a narrow lifecycle boundary: it has no trusted-client hello/resume,
browser, binary, retained-session, or broadcast state. Its outbound execution events include only
@@ -95,11 +99,11 @@ Hub authentication rejection or close code `4403` permanently revokes the local
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 before requesting remote revocation. If the Hub is
offline, the daemon persists `disconnecting` and retries revocation across daemon restarts without
opening a Hub socket. This also covers an enrollment whose request may have succeeded but whose
response was lost. `--force` removes local authority immediately and warns that remote revocation may
still be pending.
`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.
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.
| `workspace.manage` | Create, rename, archive, and remove projects and workspaces |
| `automation.manage` | Schedules, heartbeats, and loops |
| `hub.execute` | The Hub-owned execution lifecycle |
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.
Local plugins contribute daemon RPCs, native app surfaces, workspace panels, Command Center items,
composer pills, app themes, and composer attachment sources from one `index.ts`. Paseo executes the server contribution in a
subprocess and evaluates the client contribution in the app runtime.
> **Trust every plugin you add.**`paseo plugin add` and `paseo plugin install` mean “I trust this codebase.” Plugins are unsandboxed: server code and Git 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.
@@ -67,6 +67,8 @@ Pi model records expose input capabilities through `model.input`. Only send raw
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. 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 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.
@@ -75,16 +77,26 @@ OMP supports native Paseo host tools. The adapter registers the full caller-scop
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 1 keeps MCP and process environment outside the session boundary. Paseo shares one OpenCode server for ordinary agents and installs a daemon-owned plugin through `OPENCODE_CONFIG_CONTENT`. The plugin reads the exact agent environment and caller-scoped Paseo tool catalog from the daemon's private loopback bridge for each OpenCode session. Bridge context lives only in daemon memory and is removed when the Paseo session closes. The content-addressed plugin artifact contains no session data or secrets.
An agent with custom environment variables or user-configured MCP servers gets a dedicated OpenCode server. Keep that isolation until OpenCode exposes those values as session-owned configuration. 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.
`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.
Active-turn steering is an optional `AgentSession.steerActiveTurn` operation. The manager owns admission against its exact foreground turn, canonical user-message creation, echo reconciliation, and falls back to the normal interrupt-and-replace path only when the adapter reports `unavailable`. An adapter error leaves the steer's fate ambiguous and must surface without an interrupt or retry. Codex calls `turn/steer` with the native expected turn and Paseo client user-message ID. Claude pushes an admitted steer into the exact active SDK query input; isolated control commands remain unavailable. OpenCode calls `session/prompt_async` with an OpenCode-generated message ID; the server queues the prompt while busy and the next LLM call in the same Paseo turn includes it. Pi sends its native `steer` RPC, which queues the message for delivery after the in-flight assistant turn's tool calls. Slash-command inputs report `unavailable` because pi rejects extension commands on the steer path, and echo identity is correlated by message text because pi's steer RPC takes no message ID. A missing session reports `unavailable` and uses the normal interrupt fallback.
A steering adapter also owes its interrupt: stopping a turn must discard the steers the provider has not read yet, or one of them resumes the turn the user just stopped. Codex clears pending input when it aborts a turn; Claude does not, so its adapter cancels the SDK messages it queued before calling `query.interrupt()`. Pi requires `clear_queue` before `abort`; older binaries without that RPC retain their native queue behavior until the pi compatibility floor reaches 0.84.4.
`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.
@@ -103,6 +115,13 @@ Daemon bootstrap reconciles that ledger in the background, without blocking star
The daemon keeps provider snapshots per resolved working directory, with a separate semantic global scope for settings/provider management and requests that do not carry a cwd. Provider catalog probes receive a discriminated `FetchCatalogOptions`: `{ scope: "global", force }` for global catalog refreshes, or `{ scope: "workspace", cwd, force }` for project-scoped refreshes. Providers decide what global means for their runtime; do not infer global by comparing a cwd to the user's home directory.
`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.
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.
Capable clients receive a compact, content-addressed snapshot. Model rows derive their provider from the containing entry and reference snapshot-level thinking sets. The app persists that compact shape per server and cwd, then sends its hash on the next pull; an unchanged response carries no catalog body. Keep the legacy encoding for clients without the capability. The hash covers the complete client-visible compact snapshot, including status and `fetchedAt`, so explicit refreshes invalidate it even when the discovered catalog is otherwise equal.
@@ -130,6 +149,12 @@ Keep the protocol shape provider-agnostic. Do not add provider-specific renderer
Kimi Code usage follows the CLI-managed credential file at `KIMI_CODE_HOME` or `~/.kimi-code/credentials/kimi-code.json`; do not probe the legacy `~/.kimi` path as the primary source for current Kimi Code installs.
Cursor usage reads the desktop `state.vscdb` token first, then `cursor-agent`'s `~/.config/cursor/auth.json`. Headless hosts only have the CLI file.
### Usage fetchers are read-only on credentials
A fetcher reads the provider's credential file and never writes it. On a 401 or 403 it returns `unavailable` and leaves refresh to the provider's own CLI: redeeming a refresh token in the fetcher invalidates the CLI's copy (refresh tokens are single-use), and rewriting the file through the fetcher's Zod schema drops any field the schema does not model, corrupting the file for the CLI.
@@ -19,23 +19,22 @@ The first carves shipped as **deep modules with a narrow Host seam**, not the `d
- **#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 |
@@ -47,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`.
- 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
@@ -464,26 +465,37 @@ No prefix (`v`), no extra text. `Release Notes Sync` matches the `## X.Y.Z` (or
## Changelog ownership
- **The agent running the release writes the changelog entry — beta or stable.** The release context and final wording stay with that agent.
- **Commit history is only an index of the changes. Never draft the changelog from commit subjects or diffs alone.** For every PR in the release range, read the full PR description and every issue it links to before deciding what changed, why users care, or how changes should be grouped. Use the implementation only to verify the resulting understanding.
- For the first beta or a direct stable release, draft from the previous stable tag to the release source. For later betas, draft from the previous beta tag to the release source. Promotion replaces the beta series with one entry drafted from the previous stable tag to the release source. Review the result against the changelog policy below, show it to the user, and wait for approval before committing it.
## 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.
- **Use the entry's release scope.** Include changes within the matching range in **Changelog scope**.
- **Collapse internal iterations within that scope.** Present a feature added and fixed in one range as working. A later beta can describe a fix to behavior delivered in an earlier beta; promotion folds the complete beta series into the final stable behavior.
@@ -498,8 +510,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
@@ -567,6 +578,7 @@ Each beta entry records what its testers receive. Promotion produces the single
### Beta release
- [ ] The resolved release source is the intended commit (default `origin/main`) and its existing CI is green
- [ ] Every PR in the release range has been opened, and its full description and every linked issue have been read before drafting the changelog
- [ ] Add a new `CHANGELOG.md` entry for this beta (heading `## X.Y.Z-beta.N - YYYY-MM-DD`), review it against the changelog policy, get approval, and commit it before cutting the release
- [ ] 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
@@ -587,6 +599,7 @@ Each beta entry records what its testers receive. Promotion produces the single
- [ ] Run the pre-release sanity check (see above) and address any findings
- [ ] 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 replaces every `## X.Y.Z-beta.N` entry in the series with one `## X.Y.Z - YYYY-MM-DD` entry covering the full release
@@ -24,7 +24,9 @@ Terminal frames share the daemon main event loop with all agent traffic. The `ev
- **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.
- **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
@@ -41,6 +43,6 @@ Terminal frames share the daemon main event loop with all agent traffic. The `ev
## 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.
- 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.
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 but only runs when a PR touches the `desktop` filter in `.github/ci-paths.yml`. Both offenders landed under `packages/highlight/**`, which maps to `sdk`.
`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.
@@ -176,9 +186,13 @@ Test suites in this repo are heavy. Running them in bulk freezes the machine, es
- 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.
@@ -5,6 +5,9 @@ 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 daemon keeps canonical rows only for its runtime. Provider history is the durable transcript
authority and repopulates those rows when an agent resumes.
The invariants are:
> A continuously subscribed client applies every committed row in order. Opening or resuming an
@@ -12,7 +15,7 @@ The invariants are:
> 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 durable timeline rows and live stream events.
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
tool payload.
@@ -42,6 +45,16 @@ Initialization timeouts guard lack of catch-up progress, not the full multi-page
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
@@ -77,24 +90,32 @@ history start; a response requested from a pre-replacement range is stale and is
## Client replica lifetime
The host runtime owns each session replica for as long as the host remains registered. React
providers attach message handlers and UI integrations to that replica, but mounting or unmounting a
provider must not create or clear it. A provider can remount during Fast Refresh or ordinary UI
recomposition while the runtime still owns the same directory snapshot and timeline cursors.
The session projection remains host-scoped for as long as the host is registered. The viewed-timeline
@@ -335,15 +335,16 @@ 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.
- **The app shell uses one `AppearanceStyleBoundary`.** Runtime-patched numeric theme values are baked into Unistyles web classes rather than CSS variables, while parsed/memoized content also does not naturally re-run when appearance tokens change. The boundary sits below stable runtime providers in `app/_layout.tsx` and remounts the visual shell once. `applyAppearance` patches the active theme before inactive registry entries so its subscribers receive the committed values in the same update. Do not add local appearance keys or nested boundaries.
- **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`.
Paseo is a client for AI coding agents that run on your own machine. Your dev environment, in your pocket.
Paseo runs a local server called the daemon on your computer. It manages your coding agents and streams their output in real time. This app connects to that daemon, so your code never leaves your machine and there is no cloud service in between.
<b>Supported agents</b>
<ul>
<li>Claude Code</li>
<li>Codex</li>
<li>GitHub Copilot</li>
<li>OpenCode</li>
<li>Pi</li>
</ul>
You bring your own provider credentials and use your existing subscriptions. Paseo adds no markup and no inference costs of its own.
<b>What you can do</b>
<ul>
<li>Start agents, watch them work, and answer their questions from your phone</li>
<li>Run several agents in parallel across projects and git worktrees</li>
<li>Review diffs, browse commit history, and edit files</li>
<li>Work with pull requests from GitHub, GitLab, Gitea, Forgejo, and Codeberg</li>
<li>Open a terminal in any workspace</li>
<li>Dictate prompts or talk through a problem in voice mode</li>
<li>Schedule agents to run on a recurring basis</li>
</ul>
<b>Privacy</b>
No telemetry, no tracking, no account required. The app talks only to daemons you point it at. Remote access goes through an end-to-end encrypted relay that cannot read your traffic, or over your own network or VPN if you prefer.
<b>You need a daemon to connect to</b>
This app is not useful on its own. Install the Paseo daemon on the computer where your code and agent CLIs live, then pair this app with it from Settings. The desktop app bundles the daemon, or you can run it from npm, Docker, or Nix.
"build":"npm run build --workspaces --if-present",
"build:highlight":"npm run build --workspace=@getpaseo/highlight",
"build:highlight:clean":"npm run build:clean --workspace=@getpaseo/highlight",
"build:plugin":"npm run build --workspace=@getpaseo/plugin",
"build:plugin:clean":"npm run build:clean --workspace=@getpaseo/plugin",
"build:relay":"npm run build --workspace=@getpaseo/relay",
"build:relay:clean":"npm run build:clean --workspace=@getpaseo/relay",
"build:protocol":"npm run build --workspace=@getpaseo/protocol",
"build:protocol:clean":"npm run build:clean --workspace=@getpaseo/protocol",
"build:client":"npm run build --workspace=@getpaseo/client",
"build:client:clean":"npm run build:protocol:clean && npm run build:clean --workspace=@getpaseo/client",
"build:server-deps":"concurrently --kill-others-on-fail --names highlight,relay,client --prefix-colors yellow,blue,cyan \"npm run build:highlight\" \"npm run build:relay\" \"npm run build:client\"",
"build:server-deps:clean":"npm run build:highlight:clean && npm run build:relay:clean && npm run build:client:clean",
"build:server-deps":"npm run build:client && concurrently --kill-others-on-fail --names highlight,plugin,relay --prefix-colors yellow,green,blue \"npm run build:highlight\" \"npm run build:plugin\" \"npm run build:relay\"",
"build:server-deps:clean":"npm run build:client:clean && npm run build:highlight:clean && npm run build:plugin:clean && npm run build:relay:clean",
"build:server":"npm run build:server-deps && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli",
"build:server:clean":"npm run build:server-deps:clean && npm run build:clean --workspace=@getpaseo/server && npm run build:clean --workspace=@getpaseo/cli",
"build:app-deps":"npm run build:highlight && npm run build:client && npm run build --workspace=@getpaseo/expo-two-way-audio",
"build:app-deps:clean":"npm run build:highlight:clean && npm run build:client:clean && npm run build --workspace=@getpaseo/expo-two-way-audio",
"build:app-deps":"npm run build:highlight && npm run build:client && npm run build:plugin && npm run build --workspace=@getpaseo/expo-two-way-audio",
"build:app-deps:clean":"npm run build:highlight:clean && npm run build:client:clean && npm run build:plugin:clean && npm run build --workspace=@getpaseo/expo-two-way-audio",
"watch:protocol":"npm run watch --workspace=@getpaseo/protocol",
"dev:desktop":"cross-env PASEO_LISTEN=127.0.0.1:6768 npm run dev --workspace=@getpaseo/desktop",
@@ -88,7 +94,7 @@
"build:desktop":"npm run build:app-deps:clean && cd packages/app && cross-env PASEO_WEB_PLATFORM=electron npx expo export --platform web && cd ../.. && npm run build --workspace=@getpaseo/desktop --",
"db:query":"npm run db:query --workspace=@getpaseo/server --",
@@ -468,16 +487,18 @@ function expectRowContinuity(frames: TurnFrame[]): void {
`submitted row disappeared for ${Math.max(0,reappeared-disappeared)} recorded frames (${Math.max(0,(frames[reappeared]?.at??frames.at(-1)?.at??0)-frames[disappeared].at).toFixed(1)}ms) before authoritative recovery`,
]
:[]),
...(shifted>=0
...(contentShifted>=0
?[
`submitted row moved from ${baseline?.toFixed(1)} to ${frames[shifted].userRow.rect?.top.toFixed(1)}`,
`submitted row moved in scroll content from ${baselineContentTop?.toFixed(1)} to ${submittedRowContentTop(frames[contentShifted])?.toFixed(1)}`,
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.