* 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>
* 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 filesystem timeline store rewrote each agent's full transcript on every buffered update, causing write amplification that grew with conversation length. Return production timelines to runtime memory and provider-history hydration while preserving the dormant storage interface for a future intentional design.
CodeMirror's mac keymap binds doc start/end to Cmd and gives Ctrl-a the
emacs line-start behaviour, so the Control chords in these specs did
nothing on macOS and only passed on Linux CI.
* fix(app): make default send a dropdown
Keep the compact three-choice setting behind one anchored control while preserving the selected behavior and persistence flow.
* test(app): select default send behavior from its menu
The agent-message submission coverage configures send behavior through Settings, so it follows the dropdown interaction rather than the removed segmented buttons.
The dot was absolutely positioned at the chip's right edge, so it painted
on top of a truncated label. Render it as the last flex child of the tab
content row and widen a modified tab by the dot plus its gap so the label
keeps the room it had.
* feat(app): separate content typography from interface size
Readable prose benefits from its own scale without inflating navigation and controls. Preserve existing users’ rendered size during migration while giving fresh desktop installs a 15px content default.
* test(app): cover independent content typography
The appearance journey now verifies that changing interface size leaves content size intact. Timeline traversal yields when a test deliberately holds the requested page, so the helper no longer waits on its own gate.
* test(desktop): cover independent content typography
The desktop appearance regression follows the renamed Interface control and verifies it does not alter Content size.
* fix(server): preserve Pi turns through retries
Pi reports each provider attempt with agent_end, while agent_settled marks the final user-turn boundary. Keeping modern streams active until settlement prevents a recovered attempt from leaving the chat in an error state.
* fix(server): show Pi provider retries
Automatic retries keep the turn alive, but users still need to understand the delay. Surface Pi's retry-start signal through the existing non-terminal error activity row without changing terminal lifecycle handling.
* refactor(server): clarify Pi retry boundaries
Use the server's object-parameter convention for the retry boundary helpers so their event, turn, and retry semantics stay explicit as the lifecycle evolves.
Adds plugin-contributed light and dark app themes, including persistence, host-aware coalescing, fallback behavior, documentation, examples, and browser/iOS QA.
* fix(app): restore smart Changes comparison selection
The tab migration persisted the selected comparison, pinning Changes to an empty view after checkout state changed. Keep comparison overrides ephemeral per surface and expire them when dirtiness changes, while preserving only presentation state in tabs.
* chore(app): date Changes compatibility cleanup
* fix(app): only surface failed workspace setup
Running and successful setup snapshots were treated as reasons to seed a background Setup tab, adding noise to every workspace with setup commands. Seed only failed setup and classify that tab as main-workspace content so the failure stays visible without stealing focus.
* fix(app): surface failed setup once
Keep the failed-setup surfacing claim with the setup snapshot so closing its background tab remains respected after the workspace remounts. Explicitly prefer the main pane for a new failure tab without moving a user-placed existing tab.
* fix(app): keep setup tabs out of side panel
A preferred main-pane placement can fall back when users remove that pane. Keep that implicit fallback out of the focused Side panel when another workspace pane is available.
* feat(opencode): add active-turn steering via native V2 prompt
Add OpenCode support for active-turn steering (PR #3394) using the
native V2 session prompt with delivery: steer. Implements
steerActiveTurn, pending steer echo correlation for multiple steers,
prompted event handling, and definitive vs ambiguous error handling.
Updates docs and SDK to 1.18.18 for typed V2 access.
* fix(opencode): use V1 prompt for steer so model sees it
V2 prompt was admitted to a separate inbox not visible to the V1
processor, so "How old is bigbird?" was shown inline but never in
the LLM context. Switch steer to session/prompt_async (V1) which
queues while busy and is included in the next LLM call's history.
Update tests to expect V1 and keep the timeline grouping.
* fix(opencode): honor steering permission contract
Admit the steer before denying permissions that block its delivery, matching the provider steering contract. Remove the abandoned V2 path and SDK upgrade because active-turn delivery uses the existing prompt endpoint.
* chore(opencode): preserve SDK patch metadata
---------
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
* feat(app): open summarized tool calls in mobile sheets
Keep compact timelines readable by moving summarized tool-call groups into a live sheet while preserving the existing inline desktop interaction. Individual calls reuse the current detail sheet and stack above the group.
* fix(app): keep mobile tool call sheets current
Restore live group-sheet auto-follow, replace the mocked component coverage with real compact and desktop browser journeys, and remove the touched theme subscriptions.
The sidebar can be pinned to a set of hosts or to a set of labels, but
not to a set of projects. If you keep many projects on one host, the
project is the filter you want.
Add a Project page to the sidebar display menu, next to Host. It
multi-selects projects, and an empty selection means every project.
Apply the filter at two points. Project grouping reads the projects
array, but Status grouping builds its rows from the workspace entries.
A filter applied to only one of the two leaves the other mode
unfiltered.
Apply nothing when a stored selection names no visible project. The
project list is narrowed by the host filter, and it is empty before any
host connects, so dropping keys that do not match would destroy the
selection on every cold start.
Count the project filter in the sidebar header gate. Pinning every chat
in a project drops that project from the unpinned list. Filtering to
that project then removed the header, and the header carries the display
menu, which is the only way back to the Project page.
Generalise the label filter empty state to all sidebar filters, and move
its strings out of the labels namespace.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* fix(server): show Claude usage again on macOS
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(server): keep an incomplete Keychain item from hiding a working one
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* chore(server): tighten Claude keychain fix
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
Codex threads can be archived outside Paseo while their Paseo agent remains active. Repair that provider-state drift during interactive resume while preserving the existing read-only history path for Paseo-archived agents.
* feat(app): let the file panel close its tree rail
The file panel's tree rail was permanent on desktop — the changes panel
had a folder-tree toggle in its toolbar, the file toolbar had nothing, so
the rail was there whether you wanted it or not.
The changes panel's private DesktopTreeToggle becomes a shared
TreeRailToggle so both toolbars render the same affordance; that also
drops the treeToggleStyle prop threaded through ChangesToolbarProps,
which recomputed exactly what the component already built internally.
The i18n keys move out of workspace.git.diff into workspace.tree, since a
shared component reaching into the git namespace would rot.
The two rails keep independent flags — the file rail in panel-store, the
changes rail in useChangesPreferences — so closing one leaves the other
alone. FileTreeToggle wires itself to the store rather than taking a
callback: the bar renders several components below FilePanel, where the
rail actually lives.
Both rails also get narrower (320 -> 260 default, 200 -> 180 minimum).
The persist version bump resets treeRailWidth on purpose: nearly every
existing install carries the old 320 default, so without the reset the
narrower rail would only ever reach new users. Anyone who dragged their
rail to a deliberate custom width loses it once.
Note that MIN_CONTENT_WIDTH still clamps the rail to
`containerWidth - 240`, so in a narrow pane the new default never
applies — that floor, not the default, is what sets the width there.
* feat(app): make the side panel an empty workspace surface
The fixed, hideable side panel keeps supporting context beside an agent without seeding content. Pane-local choices stay local, while implicit supporting opens follow a persisted routing preference.
* test(app): exercise side panel policy through its capability boundary
The browser rail journey already covers the real tree-toggle behavior. Side-panel policy tests now pass their platform capability through the public boundary instead of replacing a production module.
* test(app): expect an empty side panel after upgrade
* test(app): expect an empty first split pane
* test(app): account for the empty side panel in split retention
* feat(app): make workspace panel tabs independent
Give explicit New actions fresh tab instances, keep panel state opaque to tab management, preserve File navigation context, and stabilize uncached File transitions.
* fix(app): preserve file conflicts during preview refresh
Keep the current preview mounted for same-target reads and conversions so the editor retains its conflict observation state. Clear retained previews only when the file target changes.
* perf(app): bound native workspace mount work
Native workspace switches retained inactive React trees and mounted large chat histories in aggressive list batches, making Hermes and Fabric work scale with prior usage.
Retain only the active native workspace, reveal a shared turn-aligned history window locally before daemon pagination, preserve desktop retention semantics, and pace native list mounting.
* test(app): cover bounded history journeys
Exercise local history reveal and hidden outline jumps through the production browser viewport while preserving held-page pagination and scroll anchoring.
* fix(app): reveal fetched outline targets
A bounded merged jump could load its target before the mounted history window, leaving the pending jump unable to scroll. Reveal fetched hidden targets through the existing history-window capability, then scroll once they mount.
* test(app): follow anchored history reveals
History-start helpers could stop at a transient edge before local window expansion restored the content anchor. Retry the real wheel or keyboard action after geometry settles until the timeline remains at history start.
* test(app): wait for history anchor release
* docs(hub): drop the manual auth secret from self-hosting setup
Hub generates its authentication secret on first start and stores it in the
database it is already using, so self-hosters no longer run openssl or carry
PASEO_HUB_AUTH_SECRET between restarts. The variable stays as an override for
deployments that must supply the secret from a platform secret store; Hub uses
it without persisting it, so removing it resumes the stored value.
Also corrects a claim that predates this change: rotating the secret does not
invalidate execution credentials already issued. Hub verifies those against
persisted hashes rather than the secret, so only browser sessions end.
Documentation companion to https://github.com/getpaseo/hub/pull/50.
* docs(hub): lead with the local guided setup
* docs(hub): document XDG data directory
* docs: make Hub self-hosting the quickstart
* fix(server): stop a steered prompt from deadlocking on a permission
Claude parks its turn inside the blocking canUseTool callback while a
plan approval or a tool prompt waits for an answer. The SDK stops to
read its input stream there, so a message sent at the open card is
queued and never read. The user must reject the card or interrupt the
agent by hand, and then send the message a second time.
Deny the pending permissions after the steer is admitted. The prompt is
already in the SDK input queue when the permission callback returns, so
Claude reads it in the same turn. Plan mode stays on and no turn
restarts.
One pass is not enough. A turn that made several tool calls opens its
next request 10 to 300 milliseconds after the previous one is answered,
so a single snapshot advances the batch by one and parks the turn again.
The Claude adapter already tracks the steers the SDK has not read, so
expose that as an optional AgentSession.hasUnreadSteer and answer each
new request while that method reports the prompt as unread. The release
stops the moment the prompt is read, so a permission the agent asks for
after it acts on the message still reaches the user.
Only the two entrypoints that carry a message from a person set the
flag. A scheduled prompt, a child-finished notification, and an
agent-to-agent prompt must not answer a card the user is looking at.
Denial left no timeline row, so a denied plan disappeared from the
transcript. Claude and Codex now push a plan_approval row with
approved: false. For Codex the row comes from resolvePlanPermission,
which is the path that dismissPendingPlanApprovals also uses.
In the app, queue mode held the message until the turn ended, and a
parked turn never ends. The composer now sends while a permission is
pending, and the button label agrees with the submit path.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(server): keep a post-prompt permission out of the release
The release loop answered the pending requests before it asked whether
the agent had read the prompt. The agent can read the prompt during a
poll interval and then open a permission request because of it. The next
pass denied that request, so a decision that belongs to the user was
made for them.
The loop now asks first and answers second. The first pass stays
unconditional, because a provider that does not report unread steers
must still get one release.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(server): make steer permission clearing provider-owned
Keep the daemon policy explicit while letting each provider queue the steer and release permissions that block it. This removes timing-based polling and preserves rejected plans as canonical timeline rows.
* fix(server): preserve omitted steer options
Only construct provider steer options when permission clearing is requested. System prompts otherwise keep the existing undefined options contract.
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
* fix(app): reuse project icon cache in new workspace
New Workspace omitted each host placement's effective icon revision, so its icon queries could not share the sidebar's memory or persistent cache entries. Carry that revision through the target projection.
* refactor(app): centralize project icon targets
Project icon consumers constructed equivalent cache targets independently. One target factory now carries each host placement revision through the sidebar, New Workspace, Projects, and Project Settings.
* test(app): inline project icon fixture
Branch-off creation reused the filesystem slug validator even when callers supplied a separate branch name and worktree slug. Validate branch refs with Git at the worktree boundary so exact valid refs are preserved while path slugs remain restricted.
* fix(app): clear mobile composer input on submit
Submitting a prompt on mobile failed to clear the native text input because EditingTextInput.replaceText previously only called setNativeProps({ text }), which is ignored on Fabric and PasteTextInput.
Fix this by:
- Delegating replaceText in EditingTextInputNative to inputRef.current.replaceText when available, clearing via clear() when text is empty, and falling back to setNativeProps.
- Patching @mattermost/react-native-paste-input to expose replaceText(text, selection) backed by native Commands.setTextAndSelection, updating clear() to use live event counts, and preventing useImperativeHandle closures over null refs during initial mount.
- Registering the patch in scripts/postinstall-patches.mjs and adding unit tests for native text input replacement and clearing.
* fix: update nix npm dependency hash
Upgrading to 0.5.0-beta.1 left the old docked explorer sidebar showing
next to the new explorer pane, with no way to dismiss it.
The sidebar rendered off the persisted `desktop.fileExplorerOpen` flag.
#3287 rerouted the explorer toggle to the workspace-layout explorer pane,
so nothing on a wide layout ever wrote that flag false again, and no
migration cleared it. Assistant directory links and review attachments
could still set it. On Windows and Linux the sidebar's close button was
suppressed because the OS window controls own the top-right corner, so
there was no affordance at all.
The explorer is now one surface picked from two facts — is the layout
compact, and can the platform render split panes:
compact -> slide-over overlay (panel store)
non-compact + splits -> dedicated right pane (layout store)
non-compact, no splits -> ordinary tabs in the focused pane
`workspace-tabs/explorer-surface.ts` owns that choice and every entry
point routes through it, so the toggle, Ctrl+E, Ctrl+., directory links,
review attachments and side file opens can no longer disagree about where
the explorer lives.
Pane splits stay gated on `isWeb`: they depend on dnd-kit and DOM-backed
accessibility helpers. Native tablets are non-compact without splits, so
they get explorer tabs rather than a second pane — deliberately trading
side-by-side agent and Changes for a surface that only renders what the
single-column tab row can actually reach.
Ctrl+. is handled by the workspace screen rather than the app shell. The
pathname alone cannot identify the active workspace: desktop cold-starts
at "/" and restores the remembered workspace from route params, so a key
derived from the pathname is null exactly when a workspace is mounted,
and the explorer reads as permanently closed.
`desktop.fileExplorerOpen` and `explorerWidth` are dropped by a migration
to panel-state v15 but stay declared in the persisted schema. That schema
is a strict object and the validated storage deletes the whole entry when
parsing fails, so removing the keys outright would wipe every upgrading
user's sidebar width, expanded paths and diff-collapse state.
The daemon imports the plugin SDK at runtime, but the workspace was named
@paseo/plugin and marked private, so it was never published. 0.5.0-beta.1
shipped @getpaseo/server depending on a package that does not exist and
`npm install @getpaseo/cli@beta` failed with a 404 for every user.
Two things hid it. sync-workspace-versions only rewrites deps matching
@getpaseo/*, so the "*" range on @paseo/plugin survived untouched into the
published tarball. And `npm pack --dry-run`, which is all release:check and
CI run, lists files without ever resolving the dependency graph.
Renaming to @getpaseo/plugin rather than publishing the old name: @paseo is
not our scope on npm, so that name could never have been published at all.
The rename also brings the package under the version-sync filter, which is
what pins it correctly from here on.
Plugins scaffolded against the old name still import @paseo/plugin. Both
spellings resolve through plugin-sdk-specifiers.ts, tagged COMPAT with a
removal date, so existing plugins keep loading.
This does not repair 0.5.0-beta.1 — a published tarball's dependencies
cannot be changed. The package publishes with the next release.
Preserve ambient placement for reconciliation and background opens while routing user-directed tab creation through the focused visible pane. Defer terminal reconciliation during creates so a push cannot preempt the intended pane placement.
Esbuild reads its binary override during module evaluation, but the previous global environment mutation leaked that host-only path into desktop-launched agents. Scope the compatibility bridge to module loading and scrub the variable from external process environments.
The plugin compiler spawned esbuild through app.asar, and the subprocess
imported the React client barrel. Point esbuild at the unpacked binary,
add a react-free @paseo/plugin/server entry, and pass a typed PluginTheme
so plugin UI follows the active theme on desktop and mobile.
Tapping a subagent or provider subagent from the composer track now opens its tab in the explorer companion pane when desktop split panes are active, keeping the parent agent visible in the primary pane.
Mobile and compact layouts continue using single-pane tab navigation, and cross-workspace subagents continue routing to their own workspace.
The checked-in bundle could not be reproduced from a clean checkout. It
was built from a working tree with hand-modified xterm sources in
node_modules that were never captured as a patch, so it carried terminal
behaviour that exists nowhere in the repository.
It was also one revision stale against our own source: 04fe30a3e flipped
the ResizeObserver call in terminal-emulator-runtime.ts to
shouldClaim: false and never rebuilt the bundle afterwards.
Rebuilding from the published @xterm/xterm picks up that resize fix and
puts the artifact back in sync with the sources anyone can build from.
The Nix grammar package imports @codemirror/autocomplete at the top level
for its keyword snippets, but only declares it as a peer dependency.
electron-builder packs node_modules by walking declared production
dependencies, so it omitted the package from app.asar and the desktop
daemon died on startup with ERR_MODULE_NOT_FOUND. Dev and CI never saw it
because the workspace hoists everything.
This is the same failure that just shipped for Svelte, and the previous
fix removed @codemirror/autocomplete on the assumption Svelte was its only
consumer. Vendoring both remaining grammars removes the class of bug
rather than the current instance: these packages are interactive editor
extensions, so importing one drags a DOM editor runtime into a headless
token highlighter.
The vendored language modules keep upstream's indent and fold props. The
web file editor consumes getLanguageForFile().extension, so dropping them
would have silently degraded editing for .nix and .cs.
The new dependency-closure test replicates electron-builder's traversal
statically and would have failed both PRs. It is scoped to
@getpaseo/highlight because that tree is small and pure; the same walk
over the server tree is dominated by false positives from optional
dependencies loaded behind try/catch.
Both advance-measurement paths measured a line by re-measuring a growing
prefix per grapheme, so one n-grapheme line shaped n^2/2 characters. The
web canvas measurer and the model fallback each carried a copy, and the
native Skia branch had the same shape via getRectsForRange(0, end) per
grapheme. Nothing caps line length client-side, so one long generated
line in a diff pinned the renderer main thread for minutes and the app
read as frozen while compositor animations kept running.
Advance measurement now has one owner in text-measurement.ts: 64-grapheme
chunks measured against an accumulated base, with chunk boundaries snapped
to shaping-safe seams so ligatures and contextual scripts keep their exact
prefix-measured widths (the xfi/Arabic contract in model.test.ts is
unchanged). A 1.2M-char line drops from a ~331-minute projection to under
a second; measurement-cost.test.ts pins the linear shaped-character
budget so the quadratic cannot come back.
The pill bar floats at the foot of the stream section and the composer has
no top padding, so the bar's paddingBottom is the entire pill-to-composer
gap. Desktop was tuned down to 10 earlier; compact kept the original 16 and
read as a hole on phone. Drop it to 8.
Same constant paddings the import pill row, which tightens with it.
observePullRequest used to reveal and focus the explorer pane, and the persisted focus leak then made ambient tab opens (sidebar agent clicks, reconcile auto-open) insert agent tabs into the explorer pane while the main pane sat empty.
PR detection now only background-adds the PR tab to the explorer pane, with no reveal and no focus change, so a tab the user already opened elsewhere stays where it is.
Placement policy is centralized in workspace-layout-actions.ts (resolvePlacementPane / insertNewTabIntoPane): explicit paneId wins, and ambient entity opens (agent, provider_subagent, terminal, draft, browser) reroute from the explorer pane to the primary pane. explorerPaneId is a required input so callers cannot silently fail open.
Pane-rail + affordances thread explicit paneId instead of focus-then-open.
* feat(app): create agent profiles from the model chooser
Restore quick model pinning by creating an agent profile directly from a
model row. A trailing plus on unprofiled model rows opens the pre-filled
profile form; rows that already name the provider+model show the profile
glyph and open it for edit.
- seed the profile form model with provider, model, and suggested name
- expose useAgentProfileEditor from @/agent-profiles for the write path
- hover-refactor model rows per docs/hover.md and add the trailing action
- wire the composer desktop selector and compact model sheet (close-then-open)
Closes#3529
* fix(app): match favourites star position for model row profile action
The star sat after the selection checkmark at the far-right edge. Put the
profile create/edit action in the same slot instead of before the checkmark.
* fix(app): disambiguate per-model create profile action
The per-model + buttons shared the accessible name "Create profile" with
the empty-state row, which broke the empty-host picker test (strict mode
matched five buttons). Name the action "Create profile from <model>" so it
is specific and screen readers get the model context.
* fix(app): keep per-model create label out of model row names
Naming the action "Create profile from <model>" put the model label into a
second button on the row, so regex selectors targeting a model row by name
matched both. Use a generic "Create profile from this model" label that
neither collides with the empty-state "Create profile" row nor with model
row names.
Port the OMP usage poller into the Pi provider so context/token usage
updates during active turns instead of only at completion.
- Add PiUsagePoller with 3s throttle, dedup, and generation-based cancellation
- Wire poller into PiRpcAgentSession turn lifecycle
- Add unit tests for polling, dedup, error handling, and turn completion
Co-authored-by: Slava Goltser <slavikg@gmail.com>
* fix(highlight): vendor pure-Lezer Svelte parser
Replace @replit/codemirror-lang-svelte with a vendored, pure-Lezer Svelte parser so the daemon and desktop app stop pulling the interactive CodeMirror editor runtime.
@replit/codemirror-lang-svelte executes EditorView.inputHandler and HTML autocompletion at module load and depends on @codemirror/view, @codemirror/state, and @codemirror/lang-* as peers. Those peers were not declared by @getpaseo/highlight, so electron-builder omitted them from app.asar and the daemon crashed on startup with ERR_MODULE_NOT_FOUND: Cannot find package '@codemirror/lang-css'.
The vendored parser uses only @lezer/lr, @lezer/common, @lezer/highlight, @lezer/javascript, and @lezer/css, with @codemirror/language kept for the existing Language wrapper. Drops the now-unused @codemirror/autocomplete dependency and adds @lezer/lr as a direct dependency.
* fix(nix): update npm-deps hash for vendored Svelte parser
Spreading ReactNative at module scope evaluated getters for legacy or unlinked modules (such as PushNotificationIOS), which called new NativeEventEmitter(null) and crashed native startup. Pass ReactNative directly to runtimeRequire instead.
After an interrupt, Claude keeps emitting messages about the request it
was told to kill — the notification for the tool it just stopped,
trailing assistant output. The router classified them as new work and
opened an autonomous turn, but the only message that could close that
turn is the interrupt result shouldSuppressStaleResult drops, so the
agent reported running forever.
Pre-existing, but masked until the steer-discard fix: the undiscarded
steer used to resume the turn and its result accidentally closed the
phantom one. pendingInterruptAbort already marks the window where
messages belong to the dying request, so the router now refuses to open
a turn while it is set.
A steer pushed into the live SDK query input survived query.interrupt():
the SDK dequeued it afterwards and resumed the turn the user had just
stopped. Codex clears pending turn input when it aborts a turn; Claude
does not, so its adapter now cancels the SDK messages it queued before
interrupting.
Interrupt-discards is the deliberate ceiling for steer cancellation:
Codex exposes no un-steer operation, so a cross-provider take-back
cannot exist. A steer the model never read keeps its transcript row.
A mixed fan-out used to collapse the pill to its single most urgent
state, so a red dot beside "1 failed" over a child that was still
working read as the whole fan-out having stopped. Every state present
now gets its own mark and count, in the sidebar's status-group order.
Steer is the better default for Enter while an agent is running, but
flipping DEFAULT_CLIENT_SETTINGS alone would only reach new installs.
Client defaults are materialized into storage — the first load writes the
whole default blob and every save re-stamps it — so every existing install
already has "interrupt" on disk, and a stored "interrupt" cannot be told
apart from one the user actually chose. A one-time migration flips them
all; picking "interrupt" afterwards sticks.
The applied-migration marker sits in its own storage key rather than in the
settings blob: StoredAppSettingsSchema is strict and readValidatedJson
clears the value when parsing fails, so an unknown field there would wipe
every setting for anyone who downgrades.
migrateAppSettings owns both of its writes so the settings blob always
lands before the marker. A failed marker write leaves the migration to
re-run harmlessly; a failed settings write must leave the marker unwritten,
or the migration is lost and the user keeps "interrupt" for good.
A running autonomous turn was excluded from steer admission because only foreground ownership was considered. That silently degraded steering into interruption and could race a provider-initiated restart, dropping the prompt.\n\nUse the provider's canonical active-turn identity for admission so autonomous work receives steering on its existing stream without replacement.
The explorer companion pane retention commit (de9e85862) auto-opens the
setup progress tab into the hidden explorer pane. toggleWorkspaceExplorerPane
only seeded the working_diff (Changes) tab when the pane was completely
empty, so once the setup tab landed there, clicking the explorer toggle
could never surface Changes/Files again via that pane's default tab.
Also fixes two e2e testid locators (workspace-new-tab-menu-trigger) and
one unit test assertion (browser automation handler) that assumed a bare
pane root, which predates that same explorer-pane-retention change: the
workspace root is now always a group with a hidden companion pane, so
unscoped locators/assertions must target the visible/named pane.
A resumed native child can be announced under a fresh tool call while retaining its session-scoped task identity. Treat later tool-call identifiers as aliases so lifecycle, usage, and timeline events continue through the original descriptor instead of splitting into contradictory rows.
* feat(app): workspace labels baseline
Existing labels implementation, committed as-is as the baseline for the
UI redesign. Server, protocol, catalog store and sync are sound; the UI
layer is reworked in the commits that follow.
Co-authored-by: Christoph Leiter <christoph@leiter.io>
* feat(app): show workspace labels on the sidebar meta line
Labels could be assigned and then appeared nowhere. They now draw as a
chip on the workspace meta row, after the host, change request, checks
and service, and they can be switched off from the Show submenu like any
other item on that line.
One palette, not two. `baseColors.workspaceLabel` was ten raw Tailwind
500s spanning a 1.9:1 to 4.5:1 contrast range — the exact set
identity-colors.ts was written to replace. Label color is now the
identity color of the same name, so a chip, a host badge and a CI check
on one row sit in one contrast band. `workspace-labels/colors.ts` and its
per-file `labelColorViolet` blocks are gone; `chip.tsx` and `swatch.tsx`
are the only places a label color becomes ink.
A row resolves its labels against its own host's catalog and nothing
else. The cross-host projection answers too, but two hosts may give one
name two colors and the merge settles that by target precedence and then
alphabetical serverId — a row painted in a color its host never assigned
is lying about the one thing the color is for.
The meta row's doc comment argued against tinted chrome on that line. The
chip is a deliberate exception — everything else there reports state and
shares the state colors, a label is a name a person chose — so the
comment now says that instead of contradicting the code.
Alignment:
- The chip is exactly as tall as the line: text at the row's 16pt
lineHeight, no vertical padding, so a labelled row is the same height
as an unlabelled one.
- A label run that opens the line hangs left by the chip's own padding,
landing the name on the rail the title above it uses. Mid-line the
ground stays in the flow.
- The swatch row reaches 44pt through hitSlop growing outward from a 20pt
swatch, replacing ten 44pt colored blobs in the manager modal.
- The picker's raw `<Plus>`, `<CheckSquare>` and `<Square>` go through
withUnistyles with module-level mappings, at the menu's 14pt.
Also fixes a bug this change exposed: `SidebarRowItemsSchema` is a strict
object, so adding an item without a key there fails the whole settings
write and silently loses every other Show toggle with it. A test now
covers each item in SIDEBAR_ROW_ITEMS.
Phase 1 of the workspace labels redesign. The filter menu, the picker's
interaction model and the manager modal's layout are later phases.
Co-authored-by: Christoph Leiter <christoph@leiter.io>
* feat(app): one Labels filter page with tri-state rows
Filtering by label cost three levels: Display -> Labels -> Include -> a page
of labels, or -> Exclude -> the same page again. It is now one page: every
label across every host, one row each, leading with its colour dot.
A row cycles off -> include -> exclude -> off. Include is MenuItem's own
check; exclude is lucide's X, the check's own pair, at the same 16 in the
same trailing slot, so the column does not shift as a row cycles. Exclude
also dims the row, because a state you have to identify by its glyph is not
one you read at a glance. Unlabelled is a row in the same list, with a hollow
ring where the others carry their colour.
Match any / all still appears only once two labels are included. Clear is
absent, not disabled, when nothing is filtered. Manage labels sits at the
bottom below a separator. The whole root row is gone when no host has a
label — unless a filter is still active, since the merged catalog only counts
online hosts and losing one would otherwise strand a filter that is still
hiding workspaces.
The page is titled Labels, so its rows stop repeating the word: "Match any",
"Match all", "Clear filter". The two Match rows sit directly under the list
they qualify and only appear once two labels are included, so the subject is
already on screen. All nine locales move together, translated rather than
transliterated — Arabic keeps its noun, where "أي" is a determiner that needs
one to stay grammatical.
SidebarLabelFilter collapses from four collections plus match to one
Record<labelKey, "include" | "exclude"> plus match: a tri-state row is one
value per label, so include and exclude can no longer disagree about the same
name. Unlabelled keys off the empty string, which normalizeWorkspaceLabelName
makes unreachable for a real label. toggleSidebarLabelSelection,
countSidebarLabelSelections, sidebarLabelSelectionIncludes and
deduplicateSidebarLabelNames go with the old shape; setLabelFilter becomes
cycleLabelFilter and setLabelMatch, so the store owns its own transitions.
No migration: labels are unreleased, so the old persisted shape never
shipped, and migrateSidebarViewState already resets on a safeParse failure.
The e2e manager step asserted "Red label color" on a swatch that Phase 1
renamed to "Red"; the selector follows the code, and the accessibility.color
key behind the old string is deleted from all nine locales as unreferenced.
Co-authored-by: Christoph Leiter <christoph@leiter.io>
* feat(app): assign labels on the menu's own rails
The assign picker was a search box, a create row that unfolded an inline form
under it, and hand-rolled label rows whose 44x44 checkbox pushed every dot and
name off the rail the rows above them sat on. Nothing lined up with anything.
Rows are `MenuItem` now: the colour dot in the 16pt leading slot, assignment as
the trailing check the engine already draws for a chosen row. Using the
engine's row is the alignment fix; there is no second row implementation to
keep in step.
Every press toggles and the menu stays open — assigning labels is multi-select,
so the dual-target model (`source: "row" | "checkbox"`,
`shouldCloseWorkspaceLabelPicker`) is gone along with the model's `close`
dependency. You leave by dismissing the menu.
Search is deleted. Nobody has enough labels to search, and the field was the
thing pushing the list down the surface. `workspaceLabels.search` and
`createNamed` go with it across all nine locales; `clearSearch` stays because
the manager modal still owns a search field (Phase 4's copy, untouched here).
`Create label` moves below a separator at the bottom and opens a page that
replaces the list: a name field, the swatch row, one commit row. Picking a
colour picks a colour — it no longer creates the label. A failed create keeps
the draft and shows the error on the page; a successful one creates, assigns,
and returns to the list with the row ticked.
Both menus share one page definition. `SidebarWorkspaceMenu` and
`SidebarWorkspaceContextMenu` each built the same `pages` memo; they now call
`useWorkspaceLabelMenuPages`.
Menu engine, all of it forced by putting a text input on a menu page:
- `MenuTextField` — the form kit's `FormTextInput` carries a screen's geometry
and lands its text 4pt inside the labels above it. This one is drawn as a
row's own fill, so the box and the text sit on the row's two rails at once.
- `MenuPageDefinition.hoverIntent` — a page you type into is not one the
pointer opens or dismisses. Without it the create flyout opened on a pointer
passing over the row, and threw the draft away when the mouse drifted off it
while the hands were on the keyboard.
- The sheet keeps `keyboardBehavior="interactive"`, not `extend`. With
`enableDynamicSizing` the largest snap point is the content's own height, so
a short page did not grow and the keyboard came up over the field. Caught on
an iOS simulator, fixed, and re-shot.
- `MenuItem` announces `selected` as `aria-checked`. The deleted checkbox said
so; a check drawn and never announced would have been an accessibility
regression. Tri-state rows (the filter page's exclude) still cannot announce
themselves — menu-wide, still Phase 5's.
`sidebar-workspace-row.tsx`'s `isTouchPlatform = platformIsNative || isCompact`
is kept deliberately. On a compact web viewport there is no hover, so the row's
trailing actions and kebab were unreachable and the labels menu with them; this
is the documented `isHovered || isNative || isCompact` shape from CLAUDE.md.
Evidence: /tmp/labels-p3-{wide,compact}-{light,dark}-{list,create}.png, the
create page on iOS with the keyboard up
(/tmp/labels-p3-native-compact-keyboard.png) and the clipped sheet it replaced
(-before.png). `packages/app/e2e/browser/workspace-labels.spec.ts` rewritten
for the new model, daemon-backed failure coverage kept for both the toggle and
the create path, green with the menu unit suites.
Co-authored-by: Christoph Leiter <christoph@leiter.io>
* feat(app): edit a label in one operation
`workspace.label.rename.request` and `workspace.label.recolor.request` become
one `workspace.label.update.request { name, newName?, color? }`. Both old RPCs
are deleted outright — no COMPAT shim, because the labels feature is unreleased
and neither name has ever been on a wire a user owns. The `workspaceLabels`
capability flag stays; that one is how a new app detects an old daemon.
Two calls could land half-applied, which left the catalog in a state nobody
asked for and the UI with nothing true to say. One catalog commit now applies
the name, the colour, or both. Renaming onto a name the host already has is
rejected rather than merged, and the check runs inside the planner so a refused
collision applies neither field. A label keeping its own key is not colliding
with itself, so case-only edits pass.
The manager modal follows:
- The host selector is `HostFilter`, the pill History and Schedules use.
`includeAllHost` is now a prop: a surface that edits one host's catalog
cannot carry out "all".
- Search moves to the sheet header, where it spans the header's own rails.
As a body control it capped at the component's 420pt and left a hole on the
right whenever a host filter wasn't beside it. `workspaceLabels.clearSearch`
goes with the box that had a clear button.
- A row no longer opens an editor. It carries a pencil in its trailing slot,
hover-revealed on web and always visible on native and compact, on the
canonical plain-View hover pattern with the press on an inner Pressable.
- The edit view is a name field, the swatch row, and one Save that issues one
update. Delete keeps its confirm and its affected-workspace count, and moves
to `outline` — red belongs to the dialog, not to the surface that opens it.
- Copy says edit, never rename or recolor, in all nine locales.
`workspaceLabelErrorMessage` drops the `requestType=… code=…` the daemon client
appends to every RPC failure. That is what a log wants, not what a user reads
under a name field.
`workspaceLabels.accessibility.color` stays deleted: the swatch's accessible
name is already the colour's name, so nothing needed it back.
Co-authored-by: Christoph Leiter <christoph@leiter.io>
* chore(app): converge the labels phases
The sweep at the end of the labels redesign: the three items earlier phases
found and kept out of their own commits, one cross-phase pass, and the docs.
`AdaptiveModalSheet`'s close button reaches its target with `hitSlop` instead
of `padding`, which had inset its glyph 8pt from the rail the title, the search
field and every trailing glyph below it sit on. This is shared chrome, so it
moves the close glyph in every sheet in the app; measured on four of them
across both paths — the labels manager desktop and compact, the shortcuts
dialog, the rename sheet — and each one's trailing rail now agrees with its
leading one.
`MenuItem` takes `selected: boolean | "mixed"`. A tri-state row was silent to a
screen reader: the check announced itself after the last phase, but exclude had
no state at all, so an excluded label was indistinguishable from an untouched
one. `"mixed"` is ARIA's own third state, the engine still draws the check only
for `true`, and the row keeps supplying its own trailing mark — nothing in the
menu engine learns what a label is.
CLAUDE.md's platform-gating rules said "NEVER use `onPointerEnter`/
`onPointerLeave`", which is the opposite of what `docs/hover.md` and
`constants/platform.ts` both mandate. It now names the real rule — hover on a
plain `View`, press on a separate inner `Pressable`, and no `onHoverIn`/
`onHoverOut` on a `Pressable` that contains other pressables — and links the doc
that owns it rather than restating it.
The cross-phase pass turned up one real defect: the replica cache never learned
about labels. `StoredWorkspaceSchema` had no `labels`, so a restored workspace
came back unlabelled, and since the directory cursor is current on reconnect the
daemon has nothing newer to send — the chips stayed gone for the whole session
and the filter matched nothing. An optional field, a serializer line, and an
assertion in the restore spec.
Everything else measured onto its rails already. Left alone and logged: the
per-row `Map` in `useWorkspaceLabelDefinitions`, and the display menu closing
when the sidebar swaps to its empty state, which is confined to that swap and
predates every phase here.
`docs/architecture.md` and `docs/data-model.md` described rename and recolor;
they now describe the one update commit, which fields it rewrites, and the
per-host resolution a row's chips use. `docs/menus.md` documents the third item
state where it already documents the other two.
Co-authored-by: Christoph Leiter <christoph@leiter.io>
* feat(app): split the labels filter into include and exclude controls
The Labels page rotated one row through off -> include -> exclude -> off, which billed the rare
operation to the common one: clearing an include cost two presses and routed through exclude, so
the sidebar visibly re-filtered into a state nobody asked for on the way out.
The row now owns include and a control on its trailing rail owns exclude. Each is one press each
way and neither is reached through the other. `cycleLabelFilter` becomes `toggleLabelInclude` and
`toggleLabelExclude`; the store's map shape is unchanged, and setting either state clears the
other for that label, so including an excluded row is still one press.
A row is never both, so the trailing rail holds exactly one 16pt glyph in every state: the check,
a permanent `X`, or the same `X` revealed on hover. `X` rather than a circle-slash for the reason
Phase 2 settled it — `Ban` fills its box where the check does not, and the column twitches. The
control fades rather than mounts and takes its 44pt target from `hitSlop`, so neither revealing it
nor changing state moves anything: measured 453-469 on every row, wide and compact.
The control carries a tooltip, gated to web where hover exists. It portals into the overlay root
above the flyout with no backdrop of its own, and resting on it does not read as the pointer
leaving the surface, so the menu stays up.
Accessibility: the control is a `checkbox` named "Exclude {label}" with its own checked state, and
it stays in the tree at opacity 0 on rows that have no visible mark, so it is reachable without
hover. Keyboard focus reveals it, so tabbing to it never means pressing something invisible. The
row keeps Phase 5's `selected="mixed"` for exclude.
Co-authored-by: Christoph Leiter <christoph@leiter.io>
* feat(app): put include and exclude on their own rails on every label row
Phase 6 gave the row include and its trailing rail a hover-revealed exclude. The rotation was gone
but the awkwardness was not: `X` is the glyph this app dismisses things with, so on a filter row it
reads as delete rather than exclude, and one rail with a changing occupant still means the row's
trailing area keeps changing identity as you read down the column.
Both controls are now on every row in fixed positions, and state is which one is lit. Nothing
appears, disappears, swaps places or shifts: the check box is 429-445 and the ban box 453-469 on an
included row, an excluded row and an untouched one alike, wide and compact. Because the ban is
always there, include -> exclude is one press instead of two. The row still includes, and the check
is that same action named.
`Ban` rather than `X`. Phase 2 rejected it because its ink fills its 16pt box where a check's does
not, so the column twitched when the two swapped on one rail. Nothing swaps here, so what is left
is a weight difference: the ban is drawn at 14 inside a 16pt box, which is the visible optical
correction available before it stops being a glyph. The box is what the rail is measured from and
it stays 16.
Lit is `foreground`, unlit `foregroundExtraMuted`. Not `foregroundMuted`, which is the obvious
choice and is wrong here specifically: the Match rows further down the same page carry `MenuItem`'s
own check in that colour to mean *selected*, so an unlit check in the same ink would say the
opposite thing eight pixels above it. Excluded keeps Phase 2's dimmed row.
Accessibility: the row keeps `selected` — `true` include, `"mixed"` exclude — and the two buttons
carry no checked state of their own, so a label's state is announced once and its two controls read
as the actions they are. Both buttons take a tooltip, gated to web with `enabledOnDesktop={isWeb}`,
because on non-compact native `TooltipContent` falls back to a `Modal` with its own backdrop.
Deleted with it: the `isHovered || isFocused || isNative || isCompact` reveal, the `opacity: 0`
mounting, the hover-tracking `View` and the focus plumbing that existed to keep an invisible control
reachable. Net -7 lines of code on this row, all of the growth in comments. The store is unchanged;
both buttons and the row press map onto `toggleLabelInclude` / `toggleLabelExclude` as they are.
The e2e's hover-reveal and `toHaveCount(0)` steps encoded the deleted behaviour and are replaced by
a rail measurement taken in every state — relative to the row, since filtering moves the anchor the
menu hangs off, and behind a settle poll, since the flyout scales in and a box read mid-animation is
not a rail.
Co-authored-by: Christoph Leiter <christoph@leiter.io>
* feat(app): reveal the label filter's controls, and stop the empty state closing the menu
Phase 7 put the check and the ban on every label row at all times, which made a resting list of
labels read as two columns of buttons with a filter somewhere behind them. A row now shows its own
state and nothing else — a marked row its mark, an untouched row nothing — and hovering any row
brings both controls up, the mark you have lit and the other one unlit beside it. So the column
you read down is the filter, and the row you are pointing at is the one offering to change it.
Nothing about the rails changed. The two boxes are laid out in every state and only their ink
comes and goes: check 429-445, ban 453-469 on an included row, an excluded row, an untouched one,
hovered or not, wide and compact. The reveal is opacity, never mounting, so it cannot move the row
out from under the pointer that fired it. Hover is tracked on a plain `View` wrapping the
`MenuItem` with the presses on the buttons inside it, since `MenuItem` is itself a `Pressable`
(docs/hover.md); each control counts its own keyboard focus as revealed, so tabbing to one never
means pressing something invisible; and `pointerEvents` follows visibility so a hidden control
cannot be clicked. On native and compact there is no pointer, so both simply stand there.
Everything Phase 7 settled is kept: `Ban` at 14 in a 16pt box, `foregroundExtraMuted` for unlit
against `foregroundMuted` meaning *selected* eight pixels below, `spacing[2]` between the two with
`hitSlop` tiling the gap, the row owning `aria-checked` while the buttons stay plain named
actions, and both tooltips gated `enabledOnDesktop={isWeb}`.
---
Excluding a label that empties the sidebar closed the display menu. Logged since Phase 2, narrowed
in Phase 5 to the empty-state swap, owned by nobody.
`SidebarLabelFilterEmptyState` was a component rendered *instead of* the list, with its own
container and scroll view, and both branches were handed the same header element. Different
subtrees, so React unmounted the header on the swap — and the header holds the display menu's
trigger, so the popover went with its anchor. The empty state is now a body: both list modes
render it where their groups would go, inside the scroll they already own and below the header
they already render, and the swap can no longer reach anything above it. Cleared from that same
still-open page, the way back out is the swap in reverse and it holds too.
`ProjectModeList` was already the right shape — its "no projects yet" body sits below the header —
but its header was conditional on `unpinnedProjects.length > 0 || hasActiveHostFilter`, which is
the same bug written as a guard: keep the header when there is a filter to undo. The label filter
joins that condition rather than getting a second mechanism. Both empty states move into
`sidebar/empty-states.tsx` so the two list modes can render either without importing each other,
which also leaves the card's geometry defined once instead of once per body.
Still conditional, and left alone: a sidebar with no projects and no filter has no header at all,
so clearing the last host filter on an empty sidebar closes the menu the same way. That one is the
onboarding empty state, where a section heading over "Add a project" would be a heading over
nothing, and no menu is reachable in the state it lands in.
The e2e gets the step that would have caught this: exclude into the empty state, assert the page
you pressed on is still up, clear from it, assert the same. Verified by reverting both list files
to Phase 7 and watching it fail on "element(s) not found". Phase 7's rail assertions are kept as
they are — offsets within the row, behind a settle poll — and joined by computed-opacity reads for
the four states of the reveal.
Evidence: /tmp/labels-qa-p8/, 28 shots plus INDEX.md — the resting list, a hovered row, an
included row with the pointer away, and the empty state with the menu still open over it, at wide
and compact in both schemes.
Co-authored-by: Christoph Leiter <christoph@leiter.io>
* feat(app): make the label filter include-only
Phases 2, 6, 7 and 8 each tried to give exclusion a comfortable shape inside a 232pt menu row:
tri-state rotation, a hover-revealed control, two permanent buttons, two hover-or-lit buttons.
Every fix bought a new awkwardness, because two controls in one menu row is a lot of machinery for
a filter. The label filter is now the host filter — one tap in, one tap out — and nobody has ever
complained about that one.
A row is a plain `MenuItem` with the colour dot leading and the engine's own check trailing.
Nothing else. Which makes the alignment law trivial to satisfy, and that is the point.
Deleted rather than disabled, since 6, 7 and 8 are in the branch history if exclusion ever comes
back: both trailing controls, the hover-tracking `View`, the `opacity: 0` mounting, the focus/blur
plumbing, the `pointerEvents` gating, both tooltips and their `enabledOnDesktop={isWeb}` gates, the
lit/unlit token pair, `Ban`, and the two rails they held.
`SidebarLabelFilter` collapses onto the shape of `hostFilters` sitting three fields above it in the
same store: `labels: string[]` plus `match`, one `toggleLabelFilter`, one `clearLabelFilter`. The
exclude arm of `filterWorkspacesByLabels` goes with it, and `filter.include` / `includeLabel` /
`exclude` / `excludeLabel` leave all nine locales. Persisted version 4 with no migration — the
record shape never shipped, and `createValidatedPersistStorage` drops state that fails the current
schema before `migrate` ever runs.
Two things this leaves unused, both deleted:
- `MenuItem`'s `selected: boolean | "mixed"`, added in Phase 5 so a tri-state row could announce
itself. The filter row was the only caller, so the union collapses back to `boolean` and the two
`selected === true` guards that existed for `"mixed"` go back to plain truthiness.
- The `docs/menus.md` paragraphs from Phases 7 and 8 on hover-revealing a pressable trailing
control inside a menu row. No caller does that any more, so the doc was describing a pattern the
codebase no longer has.
Kept: `SIDEBAR_UNLABELLED_LABEL_KEY`, because `Unlabelled` is still a row and now includes like any
other; `Match any` / `Match all`, because multi-select include still means two different things.
Phase 8's empty-state fix is untouched and still holds — `hasActiveSidebarLabelFilter` is what the
header's survival condition reads, and it answers the same question against the new shape.
Evidence: /tmp/labels-qa-p9/, 12 shots plus INDEX.md — resting, two selected, and the empty state
with the menu still open over it, at wide and compact in both schemes.
Co-authored-by: Christoph Leiter <christoph@leiter.io>
* fix(app): give label-grouped rows their project icons, and label groups a tag
Grouping the sidebar by label rendered every workspace row with its placeholder
initial. The same workspace under status or project grouping showed its real
project icon.
The components were reused; the data feeding them was not. One component served
both grouping modes and threaded the icon map through correctly. Fourteen lines
above it, a second `useProjectIcons` call was gated on `groupMode === "status"`,
written when status was the only mode that put icons on rows. Phase 2 extended
the render branch to `status || label` and left the gate alone. Nothing forces a
render branch and the data it renders to agree, and these two were far enough
apart to drift in silence. Reuse buys you one implementation of the drawing; it
buys you nothing about who decides what to fetch.
So the fix is the shape, not the line. There is now one `useProjectIcons` call
for every mode, and its targets come from `buildSidebarProjection` — the same
function that produced the rows, from the same `projects` list. A mode can only
render a row for a project the projection already knows about, so the map cannot
disagree with the screen. The gate is gone rather than extended: extending it
would leave the identical trap for the next grouping mode. `useProjectIcons`
dedupes by `serverId:projectId` and caches through `useQueries`, so the gate was
a premature optimization that bought a bug.
Pinned by `sidebar-projection.test.ts`: for each of the three modes, every
project view key the projection will render — pinned rows, grouped rows, project
headers and the rows under them — has an icon target. Red on the old gate:
restoring `groupMode === "status" ? … : []` in its new home fails project and
label grouping with `['project', 'other-project']` uncovered, and status passes,
which is exactly the bug's signature.
The sibling sweep over everything `SidebarStatusModeWrapper` is handed found one
more of the same shape, correct today: the projection chose its shortcut sections
with `status || label`, so a fourth mode would have silently walked the project
rows. Both the groups and the sections now come from one exhaustive `switch` on
the mode, which makes the next mode a compile error rather than an empty list.
`workspaceEntriesByKey` is already written the safe way round (`!== "project"`),
and the rest — host badges, pinning support, the empty-filter state — never read
the mode at all. Two names moved with the shape: `SidebarStatusModeWrapper` is
`SidebarGroupedModeList`, and its branch is `groupMode !== "project"`, so a new
mode lands in the grouped list instead of the project list's `else`.
Also: label group headers draw lucide `Tag` instead of `CircleDot`, in the
label's colour, muted for `Unlabelled` — the glyph the Labels menu and the
workspace menu already use for this feature. It keeps `StatusGroupIcon`'s 14
with no optical nudge, and that is measured rather than assumed: in the header's
leading slot both ink a 14x14 box at the same offset, and the tag lays down 76
ink pixels to the circle's 84, so reaching the corners of its box costs it no
weight beside the status glyphs.
QA: /tmp/labels-qa-p10/ — wide and compact, light and dark, label grouping and
the same workspaces under status grouping, plus the hover chevron swap, the
glyph zooms, and a before/after pair taken with the fix reverted to the bug's
shape.
Co-authored-by: Christoph Leiter <christoph@leiter.io>
* fix(app): align label chip typography
Co-authored-by: Christoph Leiter <christoph@leiter.io>
* docs: format architecture module table
Co-authored-by: Christoph Leiter <christoph@leiter.io>
* fix(app): narrow workspace labels workflow
Co-authored-by: Christoph Leiter <christoph@leiter.io>
* fix(app): mirror bottom sheet test exports
Co-authored-by: Christoph Leiter <christoph@leiter.io>
* test(desktop): assert project browse by destination
Co-authored-by: Christoph Leiter <christoph@leiter.io>
* fix(app): retain empty projects without label filters
Co-authored-by: Christoph Leiter <christoph@leiter.io>
* fix(app): preserve status grouping test contract
Co-authored-by: Christoph Leiter <christoph@leiter.io>
* fix(app): retain filters after label load failures
Co-authored-by: Christoph Leiter <christoph@leiter.io>
---------
Co-authored-by: Christoph Leiter <christoph@leiter.io>
Splitting a pane right from the default layout tore down the existing
pane and rebuilt it, losing the composer, terminal, and scroll state in
it.
Both split reducers derived the parent group from `parentPath.length`,
but `targetPath.slice(0, -1)` is empty for two different situations: the
pane is the whole tree, and the pane is a direct child of the root
group. The second case was read as parentless, so the same-direction
fast path never ran at root level and every split wrapped the pane in a
fresh group. That reparents the pane, and the renderer reconciles panes
positionally, so React unmounted the subtree. The workspace already
starts as a horizontal group holding the main pane and the hidden
explorer pane — the group was there all along, ready to take another
child.
`findParentGroup` now asks `targetPath`, so a same-direction split
appends a sibling into the group that is already running in that axis.
Perpendicular splits still nest, because changing axis needs a group.
The old test asserted the nesting as intended behaviour, which is how
this survived. It now asserts flat insertion, alongside the invariant
that matters: a same-direction split never changes an existing pane's
render path. Node id sequences shifted in three tests because the first
split no longer spends a group id, and four user splits now fit inside
the depth cap where the fourth used to be refused.
The new-tab menu handler registered itself as
`workspace-new-tab-menu:<serverId>:<paneId>`, leaving out the workspace.
Pane ids are namespaced per workspace — every workspace's default pane is
called "main" — so all workspaces on a host produced the same handler id.
The deck keeps several workspaces mounted at once, the dispatcher stores
one live handler per id, and useKeyboardActionHandler registers once per
id and never re-registers on focus change. So the second workspace to
mount evicted the first, and switching back left a mounted, focused
workspace with no handler at all: Cmd+T and Cmd+Shift+A/T/C/E silently
did nothing until the newer entry expired from the deck.
Handler ids are now built from the surface's full identity rather than
assembled at the call site, so the workspace can't be dropped again. The
three workspace-wide handlers move onto the same constructor; they were
already correct, but the shape belongs in one place.
A host can refuse a timeline read for as long as it likes. Codex does
this whenever another writer holds the thread: every catch-up fails with
"already has an active writer" until the other session lets go. The app
retried that on a fixed 1s timer with no ceiling, so an idle window sat
there issuing a request and logging a warning every second, indefinitely.
The refusal is normal lifecycle; the traffic was the bug.
Catch-up and subscription retries now double from 1s to a 30s ceiling,
resetting on success, reconnect, delivery-mode change, and visibility
change so recovery stays immediate once the condition clears.
Backing off alone would leave the user waiting up to 30s with no way in,
so both failure surfaces get a retry they can press. Background retries
stay silent; a retry the user asked for is a fallible user action and
owns its pending state, so "retrying" is a status the sync model
publishes rather than a React boolean.
The blocking "Failed to load agent" screen was the worse half: its load
effect self-gates on the error state, so a refused first resume was
terminal until the user navigated away. Its retry resets the state to
idle, which re-arms the effect.
Finish notifications and agent-target schedules now use the same steering-first delivery path as user prompts. Scheduling still decides whether a busy target should be prompted before delivery begins.
Desktop workspaces need a stable hidden explorer pane so setup and pull-request tabs have a consistent destination without changing the active pane. Mobile keeps pull requests in its explorer sidebar.
* fix(app): preserve IME composition in form inputs
Form model publication was replaying changing default text into the native or DOM editing surface. Keep the editing surface authoritative, defer web publication until composition commits, and reserve resetKey changes for explicit text replacement.
* refactor(app): centralize text editing authority
Route every app text field, including the composer and plugin runtime, through one platform-owned editing primitive. Its type and runtime reject controlled value replay, its command handle owns programmatic replacement, and lint prevents raw React Native TextInput imports outside the boundary.
* fix(app): restore composer placeholder styling
* fix(terminal): let Korean be typed in the mobile terminal
The hidden native terminal input set keyboardType="ascii-capable", which
removes the globe key on iOS — the key that switches to the Korean layout.
There was no way to reach a Hangul keyboard from a terminal at all.
Reaching the layout is not enough. receiveTextChange forwards a text change
only when it extends the previous buffer, and a Korean IME rewrites the
syllable it is composing in place: ㅎ → 하 → 한. Every keystroke after the
first jamo failed startsWith and was dropped, so Korean produced one stray
jamo and nothing else.
Forward a composition edit as a rubout plus the replacement when it rewrites
exactly one trailing Hangul character. Wider rewrites stay swallowed: those
are autocorrect and suggestion replacements, which can touch text the
terminal has already committed.
Hangul also stops travelling the keypress path. The IME reports the jamo it
is composing while the text change reports the syllable, and dispatching both
puts two conflicting characters on the wire.
* fix(terminal): stop composition rubouts after a swallowed replacement
A swallowed replacement advances previousText to text the terminal never
received, so the buffer and the terminal disagree from that point on. A
composition rubout deletes from the terminal, which means it deletes whatever
is actually sitting there rather than the character the buffer thinks it is
replacing: 한글 swallowed to 우리 then rewritten to 우루 emitted DEL and left
한루 on the line.
Latch the disagreement when a replacement is swallowed and keep rubouts off
until the line is cleared, which is where the buffer and the terminal are
known to match again. Appends are unchanged.
* fix(terminal): support CJK IME composition on mobile
* fix(terminal): guard backspace after input desync
* fix(terminal): retain desync guard after hidden input clears
* fix(terminal): preserve Unicode text on Backspace
---------
Co-authored-by: ljy900825-dev <ljy900825@gmail.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
* fix(app): defer composer updates during IME composition
* fix(app): read live DOM value from web composer getText
---------
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
Headless hosts only have ~/.config/cursor/auth.json from a CLI login,
not the desktop state.vscdb token. Without that fallback Settings usage
stayed unavailable on VPS-style machines.
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(skills): manage orchestration skills per host
Agent skills execute on the daemon host, so their bundle, selection, and filesystem convergence must share that owner. Preserve the existing install semantics while letting every capable client manage the selected host and migrating the legacy desktop preference once.
* fix(client): keep skill management internal
Keep host skill management on the low-level daemon client used by the app. Do not expose daemon settings or the desktop-only compatibility migration through the shared Paseo API handed to plugins.
* fix(e2e): declare skills fixture dependencies
Use Playwright's required destructuring form for the test-scoped daemon sandbox fixture so CI can collect the real-host skills suite.
* test(skills): use native managed paths
Assert fixed skill targets relative to the daemon user's real home with platform-native path joining so the ownership contract holds on Windows and POSIX hosts.
Wide layouts make tabs from the tab strip's + menu, so the header menu
listing new agent / new terminal / new browser duplicated it. Compact has
no tab strip, so those actions have to live in the header there.
One menu with an isMobile prop could not express that: the prop only sized
the trigger, and the item list was shared. Two components now, with the
workspace actions (import, copy path, copy branch name, show setup) in one
component both render from the same callbacks so they cannot drift.
The editor section configures the code editor, and the editor view on native is
a "not available here" stub — every control in it was dead. `webOnly` mirrors
the existing `desktopOnly` flag: the sidebar drops the item, and the section
renders nothing if a stale restored route still points at it.
Electron counts as web, so `isWeb` is the gate — desktop keeps the section.
Bulk archive keeps the panel mounted with zero rows while it is archiving or
after a failure — that is the state it lands in once it has archived every
finished subagent. The divider under the action then had nothing beneath it and
read as a hairline stranded at the bottom of the panel.
The archive-finished action sat at the foot of the track panel with nothing
between it and the subagent rows, so it read as one more subagent.
It now sits above the list behind a divider. The pointer enters the panel from
the pill below it, so the top edge is the far end of the trip: reaching a bulk
action costs a pass over every row it affects, and it can't land under a thumb
that was aiming for the nearest subagent.
It stays a row rather than becoming a Button. The panel is a menu surface, and
a bordered control inside a list of menu items reads as a foreign object — the
divider does the separating, not the chrome.
The pill drew the most urgent state of the fan-out next to the size of
the fan-out. Two questions, one pill, 8pt apart: a spinning ring beside
"2 subagents" reads as two agents running when one is. The mark is the
thing the eye lands on, so its state gets read onto whatever number is
next to it.
While anything is happening the pill now names that state and counts the
children in it — "1 running", "2 failed" — and falls back to naming the
total once nothing is. The total is a tap away in the panel, and the
running count is the number worth glancing at anyway.
Label and mark are built by one function so they cannot disagree; the
call site takes both or neither. Every non-done bucket gets copy, not
just the two that are reachable today, because needs_input and attention
become reachable the moment a subagent row carries requiresAttention,
and the failure mode is silently the bug this commit fixes.
Keep the entry point focused on contribution wiring so client UI and daemon handlers can use their own runtime APIs safely. Surface compilation failures and lifecycle transitions in the retained plugin log tail.
Track pills became menu surfaces when the trackers moved out of stacked
cards, but ComposerTrackRow kept the raw Pressable it had as an inline
card row. Nothing in that path reaches selectItem, so the popover or
sheet stayed parked over the transcript after opening a subagent, and on
iOS the action fired while UIKit was still tearing the sheet down. Rows
now hand the press to the engine and take closeOnSelect, as MenuItem
does; bulk archive opts out because its progress lands in the panel.
The running mark is the shared StatusRing rather than a still dot the
pill drew for itself, and the remaining dots read their colour from the
one bucket-to-colour map. The slot is sized for the ring in every state
so the label does not step sideways when the last child finishes.
Mounting the real menu engine in a test needed harness work: stubs for
two packages that ship untranspiled Flow, reanimated left out of Vite's
prebundle so its .web files win, and the gaps filled in the unistyles
test theme.
Layout measurement excluded pill margin on some renderers, collapsing the intended gap above visible tracks. Keep the two-pill rail on one line and derive its complete tail clearance from shared geometry so chat switching and layout callbacks cannot change the result.
Use noun labels in the add-tab menu so creation targets read consistently alongside Changes, Files, and Pull request. Keep verb-oriented labels in shortcut and command surfaces.
Keep the roomier 16px separation on compact layouts while using 10px on wide layouts. Apply the same breakpoint contract to track and import-session pills so their spacing stays consistent.
The empty-state text inherited the platform default color, making it unreadable on dark surfaces. Use the app foreground tokens so it follows the active theme.
Profile placeholders were reaching spawned processes because each menu decided whether to resolve them. Keep UI callers profile-only and perform empty-prompt resolution once when the workspace terminal is created.
Treat the appearance setting as the rendered base size so the type ramp scales predictably. Native starts one pixel larger while web and desktop preserve the authored 14px base, with legacy values migrated on load.
Keep agent and task pills as a transparent transcript overlay while measuring their rendered height for scroll-tail clearance. Share geometry and spacing with the import-session pill so borders, type, corners, and composer separation match across platforms.
Manual activation now runs once while the recognizer is waiting; reactivating or failing an already-active recognizer could leave the gesture pipeline stuck.
Cmd/Ctrl+T now opens the focused pane's launcher, while explicit target shortcuts bypass the menu. Dispatcher ownership is scoped to the app tree so retained workspaces cannot intercept commands.
The editor was trapped below the resident webview's body-level paint plane in split layouts. Render it through the shared overlay root while tracking the owning pane's bounds.
The selected segment was an inverted fill — theme foreground behind
surface0 text — which in dark mode dropped a white slab into thin
chrome like the file toolbar, and the fully rounded corners made it
read as a lozenge rather than a tab. It now uses a surface3 fill with
foreground text, and each segment takes the corner radius of the
button at its size tier.
Segments also drop one padding step below the equivalent button. The
gap between segments already supplies the visual padding, so matching
button padding made the track wider than it needed to be and ate the
breathing room around it in a 32px toolbar row.
Selection changes colour only. No weight or size change, so labels
never reflow as you switch segments.
Carries the buttonControlHeight extraction that was already sitting in
control-geometry.ts, since the two files could not be split apart
cleanly for this commit. Its callers stay uncommitted.
Pull request auto-placement was using the current location of Changes as a proxy for pane ownership. Keep the registered explorer pane authoritative so moving Changes cannot redirect automatically discovered pull requests.
Keep the last painted canvas width while a pane resize is active, then repaint during layout before the settled width reaches the screen. This avoids scaling the bitmap during the drag or on its final handoff frame.
Fast Refresh preserves refs while rerunning effects, so canceled paint requests must release their retained IDs or unseen diff regions stop rendering. Paint the final expanded body’s reserved boundary pixel because no following header owns that seam.
Reuse measured diff geometry when review controls open, keeping the interaction responsive on large changes. Preserve complete tab-track snapshots while exact content widths settle so new tabs and retained workspaces do not flash or truncate.
Keep diff headers and painted content on the same scroll clock while preserving native scrolling and independent per-file horizontal offsets.
Complete review, selection, typography, tree, and palette parity without returning line rendering to React.
Size tabs from rendered labels and switch to horizontal overflow once minimum click targets are reached, keeping the add-tab action adjacent until it must remain fixed.
Make Changes an always-expanded document with an optional desktop navigation tree, keep Git actions in their platform-owned headers, remove pinned and inline split shortcuts, and place review tabs beside Changes.
* feat(plugins): add workspace panels and command center contributions
Give local plugins contextual UI entry points without making the daemon interpret client placement or callbacks. Panels receive stable IDs and read cached client state through required selectors with shallow equality, avoiding unrelated guest rerenders and context-discovery RPCs.
* fix(plugins): align shared client state ownership
Workspace panels and Command Center callbacks consume the same frozen client snapshots, so their adapter belongs to the plugin boundary rather than either contribution surface. Refresh the Nix dependency hash for the selector-store dependency added by this feature.
* fix(app): keep check rows the same height with and without "Add to chat"
A check row was minHeight 32 plus 8pt of vertical padding, but the xs
Button in its trailing slot is 28pt tall. The button stacked on the
padding rather than fitting inside the row, so a row grew to 44pt the
moment "Add to chat" appeared and the list visibly shifted as rows
gained the button.
Rows are single-line by construction, so the row now declares a fixed
height instead of a floor a child can outgrow. That height comes from
CONTROL_HEIGHTS, the same source the button sizes from, so the two
cannot drift apart later. GitLab pipeline rows share this style and get
the fix too.
* feat(app): summarize pull request checks and group them by status
The checks section listed every check inline under a bare "Checks"
header with three count pills. On a repo with a real matrix that meant
a wall of undifferentiated rows that pushed the activity timeline off
the pane, and the pills made the reader add up numbers to work out
whether the run was actually in trouble.
The section now leads with the answer — a headline that names the
outcome, a one-line count breakdown, and a ring showing the statuses in
proportion — then groups the rows by status with failures first, and
scrolls in place past roughly eight rows instead of growing without
bound. Groups collapse individually.
The derivation lives in checks-summary.ts as one pure function, so the
headline, the ring, and the group labels cannot disagree about the same
run. The rendering, the row, and the check identity move out of pane.tsx
into an owned module; the pane now wires it and nothing more.
Running checks also gain a timing. formatCheckRunDuration required both
a start and a completion, so anything still going showed nothing at all.
It now measures an unfinished run to now. The wire deliberately carries
formatted strings rather than raw timestamps — there is a test asserting
check timestamps are stripped — so the daemon does the measuring and the
client only decides how to say it: a bare "2m" on a running row would
read as "took 2m", so those rows say "running 2m".
* fix(highlight): highlight Svelte components
`.svelte` had no entry in the extension table, so `highlightCode` fell through
to one unstyled token per line. That is the same table the file pane, the diff
tokens, markdown fences and the web editor all resolve through, so a component
rendered as plain text everywhere in the app.
Register `@replit/codemirror-lang-svelte`. Its `svelteLanguage` already wires
the mixed parsing for the `<script>` and `<style>` blocks, so the script gets
JS/TS roles, the style block gets CSS roles, and the template gets tag,
directive and mustache roles. It comes from the same publisher as the
`@replit/codemirror-lang-csharp` entry already in this package, and only the
grammar itself is declared: its CodeMirror peers resolve the same way the C#
grammar's peers already do.
* test(server): cover Svelte diff highlighting
The review surface in the issue is the diff, and the daemon is what tokenizes
it (`diff-highlighter.ts` calls `highlightCode`), so the regression belongs
here too: script, template block and markup roles on a `.svelte` hunk.
System-injected prompts never enter the Paseo timeline, but their provider runs keep distinct canonical turn IDs. Keep those IDs for lifecycle correctness while grouping adjacent output without a visible prompt into one response.
The subagents track and the task list were stacked cards above the
composer. Each one pushed the composer further down the pane and painted
an opaque band the timeline stopped at, and both spent a full row on a
count that fits in a pill.
Both are now pills that open a panel — a popover on wide screens, a sheet
on compact ones, from the existing menu engine rather than a fourth
floating-panel implementation. The row frame is shared, so a subagent row
and a task row sit on the same rail whatever their contents.
The bar mounts inside the transcript's animated container and pins itself
to the bottom edge, so scrolled content passes under the pills instead of
stopping above them. It stays a child of that container rather than a
sibling floating over the composer: on Android a view outside its parent's
bounds paints but takes no touches, and every pill is pressable. The
container carries the same keyboard transform the composer does, so the
pills track the composer's top edge when the keyboard moves.
The tracker state moves out of the composer with it. None of it was
composer state; it lived there because that is where it used to render.
* feat(cli): manage projects
Expose the existing project lifecycle through one CLI command group so projects can be managed without opening the app.
* fix(cli): preserve remote project paths
Project paths belong to the daemon when a target is explicit. Require that path instead of resolving the CLI machine's cwd, and keep local convenience resolution for the default connection.
* docs(cli): document project management
* docs(cli): remove repeated project reference
Settings detail screens must return to their logical parent regardless of the route used to enter them. Keep compact navigation in the header while preserving the desktop project-list affordance.
* feat(app): add persistent workspace side panes
Keep the common review and file-browsing workflow beside the active agent without tearing down pane contents. Workspace tabs now share one compact chip language, assistant file links route into the retained right pane, and diff/file trees share stable geometry.
* fix(app): localize pull request empty state
* test(app): update explorer pane coverage
* fix(app): preserve explorer pane workflows
* test(app): make explorer tab selection idempotent
* fix(app): persist workspace review pins
* fix(app): preserve sidebar scrim drag hook
* fix(app): restore companion pane interactions
* test(app): follow selected sidebar surface
Give selected rows their own semantic surface so selection remains visible through hover while badges, status rings, and action scrims inherit the correct backdrop.
Compact bottom sheets were still using the legacy independent-scroll gesture path, which intercepted Android sheet pans and row taps. Keep that path only for the desktop split viewport and let compact model browsers participate in sheet gesture handling.
* perf(app): keep composer typing responsive
The controlled web textarea pushed every keystroke through the draft store and broad composer tree before paint. Keep live text in the focused DOM input and publish draft and selection state after paint so persistence and reactive features remain intact without blocking the typed character.
* fix(app): preserve live composer state at actions
* fix(app): use live input for autocomplete clicks
* fix(app): keep submit activity atomic
Use the shared interface text tier across the workspace header, conversation, and composer so the working surface no longer shifts between adjacent font sizes.
* feat(agents): add active-turn steering
Let users choose whether an immediate prompt interrupts or joins the active turn. The daemon owns exact-turn admission, provider delivery, safe fallback, and canonical timeline identity while queueing remains client-side.
* fix(agents): stabilize steering history and replay QA
* fix(agents): preserve steering timeline order
* fix(agents): reconcile resumed provider history
* fix(agents): serialize steering with cancellation
* test(agents): prove Claude multi-tool steering
* test(agents): force explicit history hydration
* fix(agents): flush output before steer admission
* feat(plugins): expose Paseo API to local plugins
Plugin surfaces borrow the selected host connection while subprocess handlers receive an exclusively owned IPC session, so plugins can use normal workspace and agent operations without inventing another transport.
* feat(plugins): reload global enablement safely
Treat the global plugin switch as a live daemon setting and teach plugin automation to inspect it before acting. Enabling remains permission-gated because plugin backend code can access the daemon machine and client contributions execute inside the app.
* feat(plugins): expose plugin logs
Capture plugin stdout and stderr separately from IPC so authors can debug initialization, RPC, cleanup, and crash behavior from the app or CLI. Keep a bounded tail across lifecycle recovery while preserving structured output in the daemon log.
* fix(build): order plugin dependency builds
* test(plugins): align startup and CLI expectations
Use an overlay scrollbar for desktop chat timelines so scrollbar gutters cannot move centered content. Share its behavior with existing workspace scrollbars and derive resting and hover colors from semantic foreground tokens.
* perf(app): reduce warm workspace switch work
Retained workspaces avoided mounts but pane activity still rerendered the full composer, message input, and agent-control presentation. Keep the activity contract intact while moving focus-sensitive behavior behind small context consumers, and add a Playwright benchmark that measures rapid retained workspace switching on one browser clock.
* perf(app): accelerate retained workspace switching
Avoid repeated retained-stream layout reconciliation and unrelated workspace structure recomputation. Use the lightweight route transition while the host is already focused, while preserving targeted root POP_TO for returns from app-wide routes where it prevents duplicate hidden decks.
* perf(app): defer composer focus during workspace switches
* perf(app): retain recently used workspaces longer
Keep up to ten workspace screens warm so normal multi-workspace navigation does not repeatedly rebuild recently used timelines. Inactive screens expire after ten minutes, while the active screen remains protected and capacity eviction stays least-recently-used.
* fix(app): reconcile retained timeline geometry
* fix(app): refresh retained reading position
Let users opt into branch and project context on workspace rows while keeping the default sidebar quiet. Project names follow the existing project-icon visibility rule so grouped rows do not repeat their parent.
* fix(opencode): recover turns after event stream drops
Own the global event transport for the lifetime of the OpenCode server generation instead of coupling it to individual turns. Reconcile scoped provider state after reconnect so transient EOF, errors, and stalls do not falsely fail healthy foreground or delegated work.
* fix(opencode): settle recovery lifecycle edges
Retry only failed post-gap snapshots, preserve terminal child evidence when status is unavailable, and treat provider process exit as an authoritative stop boundary.
* fix(opencode): keep gap repair snapshot-scoped
Refresh child and blocking-request state once per reconnect, retry only rejected root snapshot reads, and settle a successful snapshot without the active dispatch instead of polling transcripts.
* fix(opencode): keep live ingress ahead of repair retries
Schedule failed snapshot retries back onto the serialized session ingress so newly arrived live terminal events run first. Keep retry backoff scoped to root reads and invalidate stale retries across later reconnects.
* fix(opencode): invalidate stale gap repairs between turns
A delayed recovery retry could inspect a replacement turn after the original turn completed. Turn creation now invalidates older repair work, and the slash-command timeout regression drives the generation-owned event source used by sessions.
* fix(opencode): bind gap repair to its turn
A queued or in-flight repair could resume after its original turn ended and read a replacement turn's mutable dispatch state. Recovery now captures its boundary and revalidates the repair revision after provider awaits and while materializing snapshots.
* fix(opencode): preserve child status on failed recovery
* fix(server): bind auto-archive to workspace PR state
Managed worktrees previously derived PR status from whichever branch was currently checked out, so checkout drift could attach an unrelated merged PR to the sidebar and archive flow. Pin PR lookup identity at worktree creation, fail closed on drift, and fan the same fresh PR snapshot shown in the sidebar to every workspace sharing that cwd.
* fix(server): serialize shared workspace auto-archive
* refactor(server): archive from one fresh git observation
* perf(app): bound large diff row mounting
Keep the existing vertical and horizontal scroll owners so virtualization does not change gesture or scrolling behavior. Reserve exact offscreen row heights while mounting only the visible window, and retain the full renderer when wrapping or editing reviews requires dynamic geometry.
* perf(app): keep large diff reviews responsive
Opening an inline editor changed the whole review action object, reconciled large syntax-token trees, and disabled row virtualization. Keep the expensive token subtree stable and preserve deterministic row geometry while the editor is open.\n\nBound server-side highlighting for pathological single lines and remove the unused client highlighter so the actual diff pipeline owns the limit.
* test(app): check mounted diff typography
Row virtualization can unmount a fixed diff row after scrolling. Assert the configured font size against a currently mounted row so the test continues to verify rendered typography without depending on window position.
* test(app): stabilize Mermaid streaming coverage
* feat(daemon): reload configuration without restarting
Keep config validation, reloadability classification, override handling, and atomic runtime application inside the daemon so local and remote clients share one authoritative workflow.
* fix(daemon): preserve reload resource ownership
Keep every materialized provider client under daemon-lifetime ownership and classify speech overrides from resolved providers. Restore the complete WebSocket config-store test contract exposed by CI.
* feat(app): archive finished subagents together
Make the track action clean up finished managed and provider-owned children as one operation while preserving running work. Managed archives remain sequential and provider-owned dismissal stays client-local.
* test(app): clarify subagent archive assertions
* feat(plugins): add local plugin surfaces and RPCs
Keep plugin execution scoped to its daemon while allowing one local TypeScript entry point to contribute validated RPCs and native client surfaces. Paseo owns host selection, navigation chrome, and per-installation query caches.
* feat(plugins): add Linear attachment source
* fix(nix): refresh npm dependency hash
* test(server): exclude internal sessions from Hub counts
* test(server): distinguish Hub execution sessions
* feat(plugins): make local plugins explicitly opt-in
Keep plugin loading dormant until configuration enables it, and collect unsupported examples under one directory.
* refactor(plugins): establish host boundaries
* fix(nix): refresh plugin dependency hash
* fix(plugins): complete packaging and startup rollback
* feat(plugins): add managed local lifecycle
Make trusted local plugins recoverable and authorable through daemon-owned lifecycle operations, host settings, and a capability-gated CLI. Reloads fully stop the previous plugin before starting new code, while generated projects provide strict TSX checking without changing runtime dependency ownership.
* fix(nix): refresh plugin dependency hash
* perf(timeline): resume cached history from its exact range
Warm launches were repainting cached history and then downloading the same bounded tail to rediscover that nothing changed. Persist synchronization authority only when the complete current canonical window can be encoded losslessly; otherwise retain the existing display-only fallback.
* test(timeline): hide replica setup behind E2E helper
* fix(timeline): bound persisted resume overflow
* test(timeline): cover workspace eviction catch-up order
* test(timeline): read persisted range from IndexedDB
* fix(app): preserve native composer input semantics
Native text was round-tripped through application state on every keystroke, which could overwrite an input method's in-flight edits. Keep native text ownership inside the input and reserve application-driven replacement for explicit draft mutations.\n\nRoute native clipboard images through the existing persisted attachment pipeline so keyboard and system paste actions behave like other image sources.
* fix(nix): refresh dependency hash
The native composer dependency changes the fixed npm dependency closure used by desktop builds.
* fix(app): keep composer locked across image pastes
Count concurrent native image persistence operations so submit remains disabled until every pasted image is attached.
* fix(app): replace composer text through the DOM on web
Web TextInput refs are DOM elements, so application-driven draft replacement must update their value and selection without native setNativeProps.
* fix(app): preserve web IME composition
Carry the Chinese IME fix from #2990 into the web composer input boundary.
Co-authored-by: Jimmy Lee <jimersylee+github@gmail.com>
---------
Co-authored-by: Jimmy Lee <jimersylee+github@gmail.com>
* fix(desktop): keep element selectors available on loaded pages
Annotate element and Screenshot element returned early when a cached load signal said the guest was not ready, even if the current document was complete and executable. Let executeJavaScript own the readiness boundary and keep selector cleanup available in the same stale-signal state.
Extend the real Electron browser regression to reproduce the complete-document/stale-signal mismatch through both toolbar actions.
* test(desktop): await browser selector injection
Poll the guest selector state through the browser bridge instead of assuming injection completes within 250 ms. This keeps the Electron regression deterministic on slower CI hosts.
* Harden browser selector session lifecycle
* Keep selector E2E fixtures independent
* fix(desktop): harden selector and annotation lifecycle
* fix(desktop): keep selector PR focused
* refactor(desktop): own element selector lifecycle
---------
Co-authored-by: Daniel G. Kang <180234654+dgk-dev@users.noreply.github.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
Reuse the existing workspace drag interaction for pinned rows and persist their local order across grouping modes. Keep pressed and dragged row scrims aligned with the active backdrop, and preserve memoized row rendering on web.
* feat(sync): restore host directories before reconnect
Persist complete project, workspace, and active-agent replicas so every registered host can paint immediately, including offline hosts. Reconcile those replicas through monotonic per-entity cursors on the existing directory RPCs without retaining an event journal.
* fix(sync): preserve active turn identity during catch-up
Own Agent-to-wire projection beside the existing snapshot normalizer so replica persistence does not reach through directory-sync internals. This also keeps identified active turns intact when unchanged agents are folded into an incremental directory response.
* test(e2e): inspect the real IndexedDB replica
Keep cache setup, reload, and hydration coverage on the production browser storage backend after the replica moved off localStorage. Measure writes at the actual IndexedDB put boundary as well.
* fix warm reconnect cache paths
* feat(sync): persist project icons and deepen directory sync
* fix(sync): preserve project update order
* fix(cache): preserve validated replica persistence
* fix(sync): retain cached agents during catch-up
* test(cli): stabilize live relay status coverage
The regression fixture deleted the supervisor's active PID lock to model a reachable daemon without local process state. Under CI load, worker readiness could race that deletion and make the supervisor tear down the daemon while status and pairing were probing it.\n\nLaunch the real worker directly so the no-PID state exists naturally, then wait on the CLI-visible live relay state before asserting behavior.
* test(cli): wait for complete relay probe state
Status readiness alone does not guarantee the next pairing RPC or the foreign-home identity probe will finish inside the CLI's per-call timeout under CI contention. Retry each exact observable outcome within the fixture deadline and resolve the real worker entry through the server package.
* test(cli): name retry mechanics once
Keep the relay scenarios expressed as CLI probes and exact outcomes while one file-local helper owns the shared deadline, worker-liveness check, and retry cadence.
Running chats expose one provider, so an intermediate provider screen and back action are redundant. Restore the direct model-list entry behavior while preserving the multi-provider picker.
Project icon discovery could select an ICO that native clients cannot decode even when a renderable SVG or PNG was available. Keep ICO as the final fallback and recognize common sized icon names.
Provider catalog discovery can exceed one minute during cold startup. Increase the default deadline while preserving explicit config and environment overrides.
* fix(app): keep profile modes scoped to providers
Applying a profile through sequential field setters allowed a provider change
to race mode persistence, poisoning modeless provider preferences with another
provider's mode. Apply profiles as one form transition and heal invalid
provider-scoped mode preferences when profiles are used.
* test(app): cover profile features across providers
* feat(cli): add Hub init wizard
Reuse the existing login, daemon enrollment, project listing, and deploy paths so onboarding stays compatible with current Hub servers. Generated triggers require an explicit sender allowlist and existing bundles are never modified.
* fix(cli): stop Hub init until daemon is ready
A durable Hub relationship can be reconnecting without being ready to execute workflows. Keep enrollment skipped, but stop the wizard until daemon status reports connected.
* fix(cli): wait for Hub daemon readiness
Both fresh enrollment and reconnect paths can expose a durable relationship before the execution socket is connected. Poll the existing status RPC with a bounded wait before resolving projects or deploying.
* fix(cli): make hub init detect resolvable resources
* fix(server): trust healthy workspace git watchers
Quiet observed workspaces were periodically scheduling full Git refreshes, saturating the daemon-wide process pool as workspace count grew. Keep only cheap observation re-ensure work, refresh ignore paths from watcher events, and detect silent metadata watchers with a one-shot canary before accepting the subscription.
* fix(server): refresh watcher ignores from git metadata
Repository exclude and Git configuration changes also affect the standard ignore set. Route those metadata events back to linked working-tree observations so their excluded directory lists stay current without periodic Git polling.
* test(server): drive ignore updates through watcher events
The integration contract now exercises the production event-driven ignore refresh instead of waiting for the removed periodic audit.
* fix(server): normalize git metadata paths on windows
File observation returns platform-native relative separators. Normalize those paths before matching Git metadata names so repository exclusion changes refresh working-tree ignores on Windows too.
* fix(server): trail concurrent ignore refreshes
Remember ignore-source events that arrive during an in-flight Git query and recompute once more before considering the working-tree observer current.
* fix(providers): make catalog refresh deadlines configurable
Provider startup can legitimately exceed a fixed short timeout when catalog discovery has substantial initialization work. Give each provider refresh one configurable deadline covering availability and the complete catalog probe, and cancel named child operations before allowing retries so timed-out work cannot accumulate in the background.
* fix(opencode): abort catalog server acquisition
A provider refresh could expire while the helper server was still starting because acquisition only tracked the activity without observing its abort signal. Reject acquisition before transferring a server reference, while leaving the manager-owned startup reusable by the next caller.
* test(file-observer): allow native burst recovery
* fix(hub): make daemon disconnect unilateral
Local relationship removal must not depend on reaching the Hub. Fence execution authority, make one bounded courtesy revocation attempt, then clear local state so status and reconnect eligibility agree immediately.
* fix(hub): preserve disconnect intent across crashes
Persist the terminal disconnect marker only while the bounded Hub notification is in flight. Startup removes that marker, so a crash cannot restore the old relationship or create a revocation retry loop.
* fix(claude): preserve native MCP timeouts
Paseo forced Claude MCP startup and tool waits to ten minutes, turning unavailable servers into long silent first turns. Let Claude apply its own defaults while preserving explicit user environment overrides.
* test(claude): isolate MCP timeout regression
Exercise timeout precedence through the existing provider runtime environment input without mutating process-wide test state.
* test(workspace-git): await refresh boundaries
Synchronize fetch observation tests with initial snapshot and fetch completion before advancing fake timers.
* fix(app): validate persisted client state
Persisted replica data could satisfy TypeScript through an assertion while missing fields required by renderers. Treat every AsyncStorage value as untrusted input, clear values that fail strict schemas, and version the replica DTO so incompatible caches cannot hydrate.
* fix(app): preserve validated legacy state
* fix(app): preserve validated preferences
Preserve supported legacy location fields and every shipped theme while keeping unknown persisted fields rejected. Align browser test seeds with the current preference shape so strict validation exercises the intended provider.
* fix(app): migrate legacy agent preferences
Released clients stored host/location fields and per-provider thinkingOptionId together. Migrate those exact strict bytes into current preferences without admitting unknown fields.
* fix(app): preserve drafts with legacy reviews
Accept the exact strict shape released for workspace review attachments so migration can discard that obsolete attachment without clearing every persisted draft.
* fix(worktrees): preserve selected base config
New worktrees were overwriting the selected ref's paseo.json with source-checkout bytes, which could create an accidental revert in an otherwise fresh workspace. Keep committed base config authoritative and warn when locally saved setup changes still need to be committed.
* fix(worktrees): seed config exclusively
* fix(projects): refresh config status on focus
Todo timeline entries gained required activity metadata in beta.1 while the persisted cache format remained unchanged. Reject version 3 snapshots so upgrades hydrate authoritative state instead of rendering legacy rows.
* fix(subagents): preserve opened children on parent archive
Opening a managed child represents user ownership only while that client's tab remains open. Track that intent per client, detach surviving children during parent archive, and keep single and bulk tab-close behavior consistent.
* fix(subagents): mark generic tab openings
Centralize managed child tab ownership in agent navigation so list, command-center, deep-link, notification, and subagent-track openings all mark ownership before creating the tab. Use stable localized errors for lifecycle failures.
* fix(subagents): classify cold tab openings
Fetch uncached agents before deciding whether tab ownership must be recorded, so notification, desktop, and cold-route openings cannot bypass parent archive protection.
* fix(app): translate navigation fallback errors
* fix(navigation): allow offline workspace intents
* refactor(app): derive subagent ownership from open tabs
* fix(server): serialize subagent lifecycle ownership
* fix(providers): restore implicit model variant selection
Provider catalogs list only explicit overrides, so treating the first override as the default made base model behavior unreachable. Represent the base choice in the catalog and translate it back to an omitted variant at the provider boundary.
* test(providers): exercise variant catalog boundary
The regression concerns the model catalog callers receive, so verify normalization through fetchCatalog instead of coupling the test to its internal builder.
Keep trailing edit controls from changing section heading rhythm, and center each profile's icon with its own text block so one-line and noted rows share the same visual alignment.
Restore the established tree rails while retaining file actions. Keep Escape local to generic editable fields so cancelling a file draft cannot interrupt an agent.
The subagent track used a custom hover path that left its label and chevron muted. Reuse the ghost button interaction so track headers promote their content consistently.
Workspace terminal shutdown only signaled an npm wrapper, leaving Electron alive with broken output pipes. Make the signal-handling runner the terminal-owned process and route external Electron termination through the existing quit lifecycle.
* fix(usage): read Cursor token from modern state.vscdb key via node:sqlite
Settings → Usage showed Cursor as unavailable for a normal Cursor desktop
login. The quota fetcher read the access token from the legacy `cursorAuthStatus`
key, but current Cursor builds store a plain JWT under `cursorAuth/accessToken`
with no such legacy row, so the lookup returned null.
Read the token with built-in node:sqlite instead of shelling out to a `sqlite3`
CLI (absent by default on Windows) — a second, independent cause of the same
"token not found → unavailable" failure. Prefer the modern key, fall back to the
legacy `cursorAuthStatus` blob for older builds, and log read failures at debug
so an unavailable card stays diagnosable.
Fixes#2586
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(usage): cover Cursor sqlite text and blob auth
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
* docs(hub): document per-execution worktree branch names
Hub now materializes `${{ paseo.execution.id }}` inside a daemon
environment's `worktree.newBranch` before it persists or dispatches the
launch, so a reusable branch-off environment can get a stable branch per
execution instead of every trigger contending for one literal name.
The reference states the constraint precisely because it is the part the
YAML cannot show: `newBranch` accepts that one execution-scoped path and
nothing else, and any other expression fails bundle activation with the
authored file and field in the error. Literal branch names stay valid.
* docs(hub): reshape the per-execution worktree branch docs
Split the two pages by the job each reader is doing. The daemon page is a
task guide, so it now carries the worktree YAML and only the two facts
that reader needs to choose a branch name, then links out. The reference
owns the exhaustive contract at the `newBranch` field: a table contrasting
a literal with the template, then the accepted expression scope and what
activation rejects.
Dropped the render-before-dispatch ordering. Readers cannot act on where
Hub materializes the value; the reachable behavior is that a retried or
recovered execution keeps its branch, which is what the table says.
* docs(hub): make the per-execution worktree branch the only guidance
A branch-off Hub environment that aims every execution at one branch name
is not a configuration worth teaching, so the docs no longer present it as
an alternative. Both pages now show `newBranch: trigger-${{ paseo.execution.id }}`
and say what it does, with no literal-branch example and no comparison.
The reference keeps the field's type as a bare syntax fact, along with the
accepted expression, what activation rejects, and the authored field the
failure names.
Stop treating heartbeat silence as proof that a live daemon worker should be destroyed. Crash recovery, explicit lifecycle requests, and supervisor-loss cleanup remain unchanged.
* feat(skills): add Paseo help and provider diagnostics
Give support agents a topology-aware troubleshooting entry point backed by the deployed documentation index, and expose the daemon provider diagnostic through the CLI so agents can inspect the affected host directly. Agent-launching skills now use configured profiles instead of the removed orchestration preferences.
* test(cli): keep provider diagnostics on real boundaries
Remove the internal client mock and preserve its normalization and host-targeting assertions through the existing isolated-daemon coverage.
Hermes in the production Android app does not provide Array.prototype.toSorted. Multi-host project selection and provider cache eviction could therefore crash when they reached deterministic sorting paths.
* feat(app): improve markdown file previews
Keep rendered documents on the chat reading rail, surface YAML front matter as readable metadata, and make shared Markdown links retain the chat accent and hover treatment.
* fix(app): preserve unsafe frontmatter source
* test(app): cover markdown link behavior
Android AsyncStorage's 6 MiB SQLite ceiling was exhausted by per-workspace provider snapshots with no eviction. Raise the native cap to 10 MiB and bound snapshots to 4 MiB with indexed eviction, clearing legacy unindexed data without scanning payloads on every save.
The worker watchdog treated wall-clock time spent in host sleep as evidence that the worker was unresponsive. Count consecutive observed heartbeat misses instead so sleep does not destroy live terminal sessions while genuinely stalled workers still restart after the same active observation window.
* fix(worktrees): honor saved setup configuration
Project Settings writes paseo.json in the source checkout, but existing committed copies in new worktrees previously won silently. Copy the saved project config before lifecycle setup so the command shown to users is the command that runs.
* fix(worktrees): replace config symlinks safely
A selected ref can place a symlink at paseo.json, so copying over that path may overwrite a file outside the worktree. Materialize the saved source config with atomic rename semantics to replace the path entry without following it.
* perf(app): skip transient replica cache writes
The replica cache subscribed to the entire session store, so live stream-head updates forced repeated JSON serialization and browser storage writes even though the head is not persisted. Select the cache's persisted inputs once and schedule writes only when that projection changes.
* test(app): move perf cleanup into fixture
Keep the replica-cache performance scenario as a direct setup, stream, measure, and assert flow while the spec-local Playwright fixture owns deterministic mock-agent cleanup.
* perf(app): remove unused session message state
The legacy message accumulator had no readers but copied its full array for each activity and updated Zustand for every assistant chunk. Keep only the live voice-abort notification and voice transcription behavior.
* test(app): use live session action in task subscription test
* fix(omp): negotiate RPC protocol v2 so large responses don't overflow
OMP caps protocol-v1 single-line RPC frames at 1 MiB. get_available_models
returns the full effective model catalog (hundreds of models), which exceeds
that cap, so OMP replies with an overflow error ("RPC response exceeded the
transport limit") and the OMP provider fails to start sessions.
Negotiate RPC protocol v2 after OMP's startup `ready` frame when it is
supported: v2 lifts the single-frame ceiling to 64 MiB by chunking oversized
frames. Add rpc_chunk reassembly to the shared JSONL transport so chunked
responses are reconstructed before routing, and gate the negotiation on the
ready frame so it is in place before the first request.
Protocol v2 is negotiated only when the peer advertises support in its ready
frame; peers that stay on v1 (e.g. Pi's --mode rpc) are unaffected.
* fix(omp): make protocol startup atomic
---------
Co-authored-by: pi3123 <pi3123@users.noreply.github.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
* feat(cli): add workspace rename command
`paseo workspace rename <workspace-id> <title>` sets a workspace's
user-visible title through the existing workspace.title.set RPC, and
`--reset` clears the override so the name reverts to the branch or
directory. The command reads the descriptor back so the output shows
what a reset reverted to.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(cli): harden workspace rename against read-back and arg failures
Review findings on the rename command:
- A failed workspace list sweep after a successful title change reported
the whole command as failed. The read-back is display-only, so it now
runs outside the mutation's error path and degrades to a null name.
- Only --reset needs the descriptor read back; a set title is already the
resolved display name, so the common path no longer pages the list.
- Dropped the `"code" in error` rethrow. Nothing in that block throws a
CommandError, but DaemonRpcError always carries a `code` property even
when it is undefined, so the guard let those through to render as
UNKNOWN_ERROR with a stack instead of WORKSPACE_RENAME_FAILED.
- Commander 12 allows excess positionals, so an unquoted multi-word title
renamed to its first word and dropped the rest. The command now rejects
excess arguments.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* refactor(cli): report the rename RPC response instead of reading back
Reading the descriptor back to show what --reset reverted to was the only
reason rename needed a paged workspace sweep, and that sweep dragged in
everything around it: a failure path that could report an applied rename
as failed, a nullable name, and a bespoke warning sink to tell the two
null cases apart.
Sibling commands don't do this. `workspace archive`, `agent mode`, and
`schedule update` all report what the RPC returned and stop; the one
command that reads back is `agent update`, which has the same "applied
but reported as failed" bug. So rename now mirrors `workspace archive`.
`--reset` reports `title: null` rather than naming what it reverted to;
`paseo workspace ls` shows the resolved name. This also reverts the
`collectWorkspaces` extraction, whose only second caller was rename.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* feat(agents): surface live task progress
Keep current task state above the composer and turn task snapshots into semantic timeline activity. Normalize provider task protocols while preserving genuine plan flows.
* fix(agents): admit tasks through stream sequencing
* test(agents): expect normalized task status
* fix(server): always create requested worktrees
A slug is a preferred name, not workspace identity. Let the worktree creator suffix occupied paths and branches so duplicate and detached-slug requests still create fresh worktrees.
* fix(server): suffix occupied checkout branches
Fresh creation also applies when checkout targets are already in use. Create a suffixed branch from the requested branch instead of rejecting the request.
* test(server): align occupied branch coverage
* test(server): remove stale worktree reuse expectations
* Search models across all providers from the picker root
* Store agent profiles in daemon config and apply agent config in one RPC
An agent profile is a named bundle of provider, model, mode, thinking
option, feature values and notes, stored host-wide next to terminal
profiles. It carries no system prompt: AgentSessionConfig.systemPrompt is
creation-only, so a profile holding one would apply when starting an agent
and silently do nothing when applied to a running one.
Applying a profile through the four single-field RPCs can fail halfway and
leave an agent matching no profile, so agent.config.apply takes the whole
bundle. Model runs first because a thinking option only means anything
against the model that offers it.
Old daemons parse their persisted config strictly and would drop an
unknown agentProfiles key, making a save appear to succeed and vanish, so
both capabilities are advertised on server_info for the client to gate on.
list_profiles exposes the profiles to orchestrating agents, which read the
notes to pick one and copy its values into create_agent.
* Add the agent profiles settings section
Profiles live behind one module boundary so removing the feature is a
directory plus one mount, which is what favourites never was. The picker
and composer will ask for rows and an apply function rather than learning
what a profile contains.
The edit form follows the form-model pattern instead of the terminal
profile modal's per-field state, because provider selection cascades into
which models, modes, thinking levels and features exist below it.
The section is gated on server_info.features.agentProfiles: an older daemon
drops the key on write, and a save that appears to work and then vanishes
is worse than an unavailable state.
* Document agent profiles and name the four senses of profile
The glossary reserved Profile for HostProfile, terminal profiles owned the
user-facing word, and custom-providers.md used it for provider aliases.
Agent profile is now the term, with all four cross-referenced.
data-model.md never documented terminalProfiles either, so both profile
lists are backfilled together along with the whole-list patch semantics and
why absent and empty differ.
* Pin agent profiles to the model picker and delete favourites
Applying a profile writes its values into the agent controls and is then
forgotten, so rows carry no checkmark and no selected state: after applying,
nothing is selected. Chevron still means navigate deeper, so the absence of
one is what marks a row as an action.
A running agent is its provider's process and agent.config.apply carries no
provider, so the live picker only offers profiles matching the agent's
provider rather than showing rows that cannot do what they say. The draft
path has no such limit and switches provider freely.
Draft feature values go through preferences rather than the feature
callback alone, because the draft feature hook clears local values whenever
the provider changes and a provider-switching profile would lose them.
Favourites are deleted rather than migrated: they were device-local
AsyncStorage keyed by provider and model, while profiles are host-wide
daemon config. ProviderSelectionModelRow.favoriteKey stays as row identity
and moves to its real owner; renaming it is its own commit.
* Cover agent profiles and cross-provider search with E2E specs
Model rows expressed selection only as a checkmark glyph, which assistive
tech cannot see and a spec cannot assert. Rows that draw the indicator now
carry aria-selected; profile rows deliberately carry none, because a
profile is an action rather than a selection. That is what makes "nothing
is marked selected after applying a profile" a real assertion instead of a
tautology, with the materialized model one level down as the control.
The search fixture extends claude rather than mock: provider-config.ts
only allows the six shipped providers as extends targets, so a derived
provider cannot inherit the dev-only mock provider.
* State what applying a config bundle does on partial failure
applyBundle is not a transaction and the earlier commit message implied it
was. A step that rejects aborts the rest and the steps that already applied
stay applied, which a profile omitting modelId can reach: provider setters
validate against the agent's live model, so mode can land while a feature
that model does not support is rejected.
Nothing is hidden by that. Every setter emits agent state before returning,
so what applied is streamed to the client next to the rejected response.
Rolling back would re-invoke the setters that just failed, usually against
a session that is gone, and pre-flight validation would restate each
provider's model-dependent checks where they would drift from the setters
that own them.
What sending the bundle in one request removes is the multi-round-trip
failure class: interruption between hops, and interleaving with another
client. The test already pinned this behaviour; its name now says so.
* feat(agent-profiles): refine profile discovery and appearance
Make profiles discoverable from empty and provider-scoped model pickers, keep global search stable and virtualized, and move profile settings under Agents. Replace emoji identity with a shared Lucide icon and host-color picker, while requiring concrete provider selections so profiles materialize predictably.
* fix(agent-profiles): defer profile swatch styles
Resolving Unistyles proxies at module load can freeze the temporary startup color scheme before the persisted theme is restored. Keep only style-key names at module scope and resolve the selected swatch during render.
Switching the sidebar between Project and Status grouping takes three
clicks today: open the display preferences menu, open Grouping, pick the
mode. It is the only sidebar preference with no fast path.
Typing "group" in the command center now offers a single entry that
switches to the mode you are not in, so it reads "Group by status" while
grouped by project and the reverse once you are in status mode. With
two modes a "cycle grouping" label would not say where you land, and an
entry that reads the same in both states looks like it does nothing.
The entry is query-only, so the default Cmd-K list is unchanged. It does
not clear the focus restore element, because it navigates nowhere and
focus has to return to whatever you were typing in.
The contribution is built by a pure function in root-contributions.ts so
its branches can be unit tested, mirroring workspace-contributions.ts.
The registration subscribes to groupMode with a narrow selector to avoid
re-registering every root action when host filters are reconciled.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Fix spelling and grammar errors found in a full sweep of the
repo's English markdown files.
- CONTRIBUTING.md: "contributing to to it", "accpeted", "wil"
- .github/PULL_REQUEST_TEMPLATE.md: "value provider to" instead
of "value provided to"
- docs/product.md: "vendor-lock in" instead of "vendor lock-in"
- packages/expo-two-way-audio/CONTRIBUTING.md: "prescreptive"
- packages/expo-two-way-audio/README.md: "can bee added"
- skills/paseo-committee/SKILL.md: "considered and reject"
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Development web and Electron builds now show their source branch in the expanded sidebar, including a branch icon and safe truncation. Dev launchers inject the label at build time; production builds remain unchanged.
Theme registration, the appearance picker, persistence, and shortcut cycling drifted as new choices were added. Make one ordered catalog own those paths so Auto and Pure Black participate consistently, while lifting the Pure Black selected-workspace surface above its sidebar.
Expose Automatic and Manual metadata model selection in Host Settings, preserve existing configured fallbacks, and keep structured generation resilient through discovered candidates. Includes Playwright coverage, translations, public documentation, and settings-row alignment polish.
* perf(app): virtualize large diff rendering
Render bounded diff row groups through one vertical list and one horizontal surface. Bound retained list ranges when expansion grows the data set so large diffs paint promptly without sacrificing scrolling behavior.
* refactor(app): normalize working tree diff options
* fix(app): keep wide diff text reachable
* feat(app): render mermaid diagrams in agent chat with pan/zoom
Fenced mermaid blocks in assistant messages now render as diagrams
instead of code blocks.
Web: mermaid renders inline via dynamic import (code-split, loads on
first diagram). Drag to pan, ctrl/cmd+wheel or trackpad pinch to zoom
about the cursor, double-click to reset; plain wheel still scrolls the
chat. Rendered SVGs are cached across virtualization remounts.
iOS/Android: diagrams render in a WebView running a self-contained
generated bundle (build:mermaid-webview, mirrors the native terminal's
xterm pattern — no CDN, mermaid ships in the app). The inline preview
is touch-shielded and height-driven by a bridge message; tapping opens
a fullscreen viewer where the platform WebView owns pinch-zoom.
Streaming source that doesn't parse yet falls back to the highlighted
code block and keeps the last good render; renders are serialized
through one queue with stale-task skipping so token-by-token updates
cost at most one in-flight layout.
Security: mermaid can fetch external resources during layout (image
shapes, CSS url()) before output sanitization runs — see
mermaid-js/mermaid#7645 — so resource-bearing source is rejected
up front (containsUnsafeMermaidSource) on every platform, htmlLabels
stay off, securityLevel is strict, and the mermaid secure list is
extended so directives can't override app theming or themeCSS.
The lockfile diff includes npm 11.17 normalization churn beyond the
mermaid closure; a no-op npm install produces the same rewrite.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(app): serialize native mermaid renders and pin viewer to last good source
Serialize render messages inside the mermaid webview entry so a newer
streamed update can't re-initialize global mermaid state (e.g. flip the
theme) while a prior layout is still running; superseded queued renders
are skipped. Track the last successfully rendered source on the RN side
and open the fullscreen viewer with that, so tapping mid-stream while
the latest chunk is still invalid can't show a blank viewer.
Addresses PR review feedback.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(app): pair native mermaid renders with their source via request id
A "rendered" bridge event was credited to the newest sent source, but an
older render can complete while a newer (possibly invalid) chunk's send
is in flight, mis-crediting the older render's success to the newer
source. Render messages now carry a request id that the entry echoes on
rendered/renderError; the RN side resolves the source by id, so the
fullscreen viewer always opens the exact source that produced the
visible render.
Addresses PR review feedback.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(app): harden mermaid guard and native preview per adversarial review
- Guard bypass (blocker): mermaid accepts quoted and unicode-escaped
object keys, so quoted "img": evaded the resource denylist while
still parsing into a real img property. The guard now decodes u/x
escapes and strips quoting characters before matching, and tests
both raw and normalized source.
- structuredClone polyfill banner in the webview bundle: the app's iOS
floor is 15.1 but structuredClone exists only from 15.4; esbuild
targets don't polyfill runtime APIs.
- Preview height sizes the inner content view so fence padding/borders
no longer clip the reported SVG height, and measurement happens at
the final content width (measuring overlay keeps the fence insets).
- The invisible measuring overlay is hit-test transparent, so an
invalid diagram's fallback code block keeps its copy button and
gestures.
- renderError now prunes all superseded source-map entries, matching
the rendered path, so a long broken stream can't accumulate sources.
- The entry's render chain can no longer be poisoned: the whole task
body is inside try/catch and the chain appends a trailing catch.
- "Mermaid diagram" accessibility label is translated in all locales
(message.mermaidDiagram) on web and native.
- esbuild pinned in packages/app devDependencies so the generated
webview artifact doesn't depend on hoisting layout; </script is
escaped in the generated JS.
Addresses adversarial review round 2.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(app): mermaid source toggle and visible zoom controls
Web: a hover control cluster on the diagram (zoom in/out anchored to
the viewport center, reset view, view source) mirroring the code
block's hover-copy idiom; always visible on compact form factors where
hover doesn't exist. View source swaps to the highlighted code block
(with its own copy button) and a view-diagram button swaps back.
Native: the fullscreen viewer gains a source toggle beside the close
button, swapping the webview for the highlighted source in a
scrollable view.
All control labels are translated in every locale under
message.mermaid.*; the flat mermaidDiagram key from the previous
commit is folded into the same group.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(app): align mermaid control icons with the app's icon vocabulary
Network is the settings Connections icon — reusing it for "view
diagram" gave one glyph two meanings; use Workflow instead. RotateCw is
the app's established reset/reload glyph (RotateCcw had no precedent).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(app): use Scan for mermaid reset-view control
RotateCw is the app's reload glyph (19 uses) — a reset-zoom button
wearing it invites a reload misread. Scan (corner-bracket fit-to-frame)
is unused and carries the intended fit/reset semantics.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(app): remove raw NUL byte from mermaid cache key separator
A literal 0x00 in the template string made git classify the file as
binary, rendering the PR diff for it unreviewable. Use the escaped
sequence instead — identical runtime key, plain-text source.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(app): align mermaid source-view toggle with the copy button
The source view wrapped the code block while the fence margins stayed
inside it, so the floating view-diagram toggle hung in the margin
whitespace above the visible box — misaligned with the code block's
copy button and outside its hover region. Hoist the margins onto the
wrapper (both buttons now share the box edge) and render the toggle in
the copy button's plain style so the pair reads as one control row.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(app): close mermaid review findings
* chore(app): finish Mermaid PR rebase cleanup
* refactor(app): isolate diagram rendering and stabilize streaming
* fix(app): keep Mermaid previews stable while streaming
* fix(nix): update npm dependency hash
* fix(app): preserve streamed rows through completion
---------
Co-authored-by: Damon Meledones <dmeledon@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
* fix(agents): preserve permission notification callbacks
Permission requests consumed the one-shot completion watcher, so callers never heard about later approvals or the final result. Keep the watcher alive across permission checkpoints and include the actionable request payload in each notification.
* fix(agents): notify concurrent permission requests
* fix(notifications): stop pushes after host removal
Push tokens were persisted indefinitely with no relationship to app pairing state. Give registrations a 48-hour renewable lease so offline unpairing converges, and revoke immediately when the host is reachable.
* fix(notifications): make token revocation bounded and durable
* fix(app): restore composer control tooltips
Composite composer triggers dropped tooltip interaction props or the anchor ref, so hover either never opened the tooltip or left it off-screen. Preserve pressable props and compose menu refs at the trigger boundaries.
* fix(app): align mode tooltip typography
* fix(hub): send hostname during daemon enrollment
Pass the local hostname as daemon enrollment metadata so Hub can assign useful default slugs without coupling daemon authority to the human CLI login. Document the rollout dependency on Hub accepting the optional field.
* test(hub): verify enrollment hostname payload
* refactor(storage): remove chat rooms and agent loops
This is the prerequisite cleanup for the storage backend migration. Legacy wire schemas remain temporarily so mixed-version peers can still parse old messages, while daemon callers now use query-shaped AgentStorage methods that can map directly to SQL later.
* test(cli): remove retired chat and loop coverage
* fix(app): preserve dictation audio across transport failures
Keep captured PCM in the existing local segment buffer when a WebSocket send fails. Finish only after server acknowledgements and fail an idle drain so the retry path can resend the complete recording.
* fix(app): dispose dictation stream sender cleanly
* feat(command-center): add workspace file search
Use the existing workspace-aware fuzzy search so file lookup follows the file explorer's ignore and hidden-directory rules. Cmd/Ctrl+P opens a removable Files scope while the normal command center also includes file matches.
* fix(command-center): stabilize file search results
* fix(shortcuts): preserve project picker command
* fix(command-center): keep file results while searching
* feat(command-center): integrate the files scope into the search row
The scope was a 24px pill with 12px text sitting inside the 16px input line, so
it read as a badge dropped into the query rather than the scope of the search.
Make it a leading cell of the search row instead: folder icon, input-sized label,
and a hairline divider before the caret. It stretches to the row's cross size and
bleeds through the header padding with negative margins, so its height is driven
entirely by the input and the panel's own radius clips its corner. Toggling the
scope cannot resize the header, which the new spec pins.
* fix(command-center): restore the files scope as a chip
The previous commit turned the scope into a flush leading cell of the header. Put
the chip back: rounded, bordered, folder icon and 14px label, no divider.
It stretches to the row instead of carrying a height, so it is exactly as tall as
the input's line box and cannot resize the header.
* refactor(shortcuts): regroup and reorder the shortcut help sections
Panels was a junk drawer: the command center, file search, the shortcuts sheet
itself, new terminal, the sidebars, settings, focus mode and theme cycling. The
two shortcuts people reach for most, Cmd+K and Cmd+P, sat inside it below three
other sections. Navigation held both workspace and tab movement.
Sections are now General, Projects & Workspaces, Tabs & Panes, Layout, and Agent
Input. General leads with the command center and file search. Tab movement joins
Tabs & Panes, new terminal joins the tabs it creates, and Layout keeps only the
sidebar and focus toggles.
Row order is now data rather than a side effect of binding-definition order, so
the row someone opens the sheet to find sits at the top of its section. A test
fails when a new shortcut has no place in that order instead of letting it sink
to the bottom unnoticed.
* Keep file search failures in view
* docs(hub): align public docs with v0.3
Bring the public workflow, API, authority, activity, and self-hosting guidance in line with the released Hub contracts. Clarify the public HTTPS boundary required by provider events while keeping Hub and PostgreSQL private.
* docs(hub): clarify daemon logout scope
* docs(hub): restore Compose path and simplify TLS guidance
* docs(hub): link the single-repo-team-bot example
* feat(client): publish the TypeScript SDK surface
Make the high-level client API usable for external integrations and document complete agent, workspace, provider, event, and recipe flows.
* fix(client): correlate provider snapshots by daemon cwd
Return the daemon's canonical snapshot identity so waitForReady can match updates for relative, tilde, and otherwise non-canonical paths without duplicating filesystem rules in the SDK.
* test(server): make snapshot cwd assertion portable
* fix(client): gate canonical provider snapshot waits
Older daemons do not expose the canonical snapshot identity needed to correlate workspace updates safely. Require the advertised capability and fail with an update-host message instead of guessing across workspaces.
* feat(hub): deploy canonical workflow bundles
Keep CLI deployment aligned with Hub's authored bundle boundary so validation and activation receive the same deterministic source files. Public documentation now teaches that boundary as the only configuration format.
* test(hub): assert validation diagnostics directly
* refactor(hub): deepen HTTP client module
* feat(hub): validate named agent configurations
* docs(hub): align Claude mode example
* Render SVG project icons on native and fall back for ICO
React Native's <Image> silently decodes neither SVG nor ICO, so any
project whose discovered icon was one of those formats showed a blank
slot on iOS/Android while web/Electron rendered it fine. Route SVG data
URIs through react-native-svg on native and let ICO fall through to the
initial-letter avatar instead of rendering nothing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Import Buffer explicitly so SVG icons actually decode on device
React Native has no global Buffer and the app installs no global polyfill,
so projectIconSvgXml threw ReferenceError inside its try/catch and returned
null on every SVG — the exact blank-slot bug the previous commit meant to
fix. Vitest runs in Node where Buffer is global, which is why the tests
passed anyway; the new regression test deletes globalThis.Buffer to keep
the import honest.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Prefer renderable icon formats and unwrap PNG-bearing ICOs
favicon.ico ranked first in ICON_PATTERNS, so any repo with a standard
public/ layout served ICO — which native <Image> cannot decode — even when
an SVG or PNG sat in the same directory. Rank SVG and PNG above ICO for
each name, sniff magic bytes instead of trusting the extension (renamed
PNGs shipped as image/x-icon are common), and extract the largest
PNG-encoded frame from real ICO containers so native clients get a
decodable image instead of falling back to the letter avatar.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Render SVG icons with CSS support and fall back on parse failure
SvgXml ignores <style> blocks, which favicon SVGs commonly use, leaving
paths with a default black fill — invisible on dark themes. SvgCss inlines
the styles before rendering. Pass the letter avatar as the fallback so a
malformed SVG degrades to it instead of an empty slot, and fold the
workspace setup dialog's hand-rolled icon cascade into ProjectIconView so
the format handling lives in one place.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Take the platform as an explicit parameter in icon source helpers
The tests previously pinned isNative to true with a module-level vi.mock,
which coupled them to import initialization and left the web pass-through
paths uncovered. Defaulting an explicit `native` parameter to the real
constant keeps call sites unchanged while letting tests exercise both
variants directly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(app): isolate native project icon rendering
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
* fix(app): remove search from project method selection
The fixed method list should not accept text because it can be mistaken for the directory path step that follows.
* fix(app): preserve native project picker keys
Keep hardware-keyboard delivery owned by the Add Project flow even when method selection has no visible text field.
* test(app): update add project overlay expectation
* docs(hub): name the Hub tools in workflow prompts
Every Hub workflow example now tells the agent which tool to call:
hub.reply for the user-facing response, hub.finish_execution to
complete the step and record structured output. Granting a capability
through allow_outputs makes the tool available; it does not make an
agent use it, and an agent that is not told answers in its own
transcript and leaves the step to its idle timeout. Explicit
instructions are what worked against real agents.
Classifier and other intermediate steps name hub.finish_execution
alone, so the docs do not imply every step must reply.
Drops the github.reply allow_outputs blocks from the GitHub examples.
The GitHub provider registers no output capabilities, so that
declaration never materializes a tool; those agents comment through
their scoped GH_TOKEN with gh, which the reference and trigger pages
already said. The read-only answer branch has no network authority for
that, so it only finishes.
Also quotes the "Request: ${{ paseo.prompt }}" prompt block in the
reference, which was invalid YAML as written.
* docs(hub): wrap triggering input in its own prompt block
Every Hub YAML example now ends its prompt with the raw triggering
message wrapped in <user-prompt> tags, as a separate block after the
workflow instructions. The agent can see where your instructions stop
and the request starts, which matters most on the steps that read
external Slack, Discord, and GitHub text.
The first workflow explains the convention in two sentences and points
at Hub security for the controls that actually limit an agent. Tags
label the input; they are not containment, and the docs do not claim
otherwise.
Replaces the earlier quoting fix in the prompt-blocks reference: the
message now lives in a block scalar, so the colon needs no quoting.
* docs: base worktree examples on origin/main
Every branch-off example now passes a remote-tracking ref. Paseo
fetches remote refs in the background, so origin/main is current while
the local main is whatever was last pulled, and base resolution tries
refs/heads first: an unqualified main silently branches off stale
history. The gotcha is explained once, next to the first branch-off
example in the worktrees guide; the other pages just use the ref, and
the Hub environment reference links there.
Also points the safety-gate classifier at the structured-output
section, which shows the output wrapper the finish call takes.
* docs(hub): configure Codex read-only through provider options
Hub examples described a read-only Codex step as mode: read-only. That
Paseo mode preset pairs the read-only sandbox with approval_policy
on-request, so an unattended Hub step stops to ask a question nobody is
there to answer. It is also not one of the modes the Codex provider
advertises.
Every read-only step now uses the provider-native passthrough proven by
the real-provider evidence in #3025: approval_policy never,
sandbox_mode read-only, web_search disabled. The security guide already
used this block; the workflow guide, examples, and reference now agree
with it, and the reference says plainly that a Paseo mode ID is a
separate surface from these options.
mode: full-access is unchanged. It maps to approval_policy never, so it
does not stall an unattended step.
* docs(hub): show a classifier choosing the repository and the model
Both routing jobs were only documented as caller-supplied inputs, so
there was no example of the thing people actually want: one read-only
classifier that reads the request, picks the repository it belongs to
and the model it needs, and hands both to a worker. Replaces the
caller-only model example, which the new one covers through its
override values.
The two axes are not symmetric and the example says so. A model is a
value, so values.selected_model goes straight into agent.model. An
environment cannot come from an expression at all: the compiler accepts
a literal name or paseo.inputs.<name> and nothing else, so each
repository needs its own environment and its own conditional step. One
classifier, exactly one worker.
Corrects the routing section, which listed environment among the fields
an agent output may select. It never could.
Also puts a blank line between adjacent steps and environments in the
deadline and repository-routing examples, and bases the daemon worktree
example on origin/main, which the earlier sweep missed.
* fix(website): keep development requests local
Canonical host redirects were applied to LAN-addressed development traffic, including inspector WebSockets. The redirected socket retained the Vite port and caused the development proxy to terminate when the public host timed out.
* docs(hub): order the workflow scenarios from simple to hard
The Hub pages were arranged by the shape of the configuration schema
rather than by what a reader is trying to do, which showed up three
ways: the overview opened on the storage hierarchy, workflow
configurations were split between workflows.md and a separate examples
page, and the examples that existed started with the hardest one.
workflows.md is now a ladder. One Slack trigger answering in a thread
stays inline as the first workflow, and nine further scenarios sit
under it as collapsible blocks, each a complete configuration adding
one idea to the one above: full access, repeated replies, a GitHub
trigger, two ordered steps, a typed input, two triggers, a classifier
choosing a model, a classifier choosing a repository, and last the
override composed with ??. configuration/examples.md is removed and its
configurations live there now.
Two claims in the old material were wrong and are corrected. Nothing
carries between steps except a declared output, so the planning
scenario passes its plan through output.schema instead of telling the
second agent to follow a plan it cannot see, and a worktree environment
creates a worktree per execution rather than sharing one. GH_TOKEN
authenticates the gh CLI but does not configure git, so the pages no
longer imply a step can push without a commit identity and credential
helper on the daemon host.
The overview drops the organization/connections/daemons/projects tree,
which concepts.md already owns, in favour of the smallest configuration
that does something. Hosting is now a link from the product pages
rather than a section in them.
* docs(hub): make GitHub access an explicit step grant
GitHub authority gets its own page. A step holds a token only when it
declares a github block — connection, repositories, permissions,
duration — and Hub supplies GH_TOKEN plus environment-only git
configuration, revoked when the step ends. No credential is ever
implied by the trigger source.
Rewrites the issue-to-pull-request scenario onto the block, repoints
the trigger, reference, FAQ, and concepts pages, and sweeps the Hub
pages for announcing sentences and compressed paragraphs.
* docs(hub): lighten dense reference prose
Restructure paragraph walls into short paragraphs, lists, and code
blocks: logout behavior in daemons, deploy paths and precedence in
configuration, install limits and dispatch fields in the API page,
required outputs and partial resolution in the reference, and the
policy translation and secrets guidance in security. Fixes two
remaining trigger-derived credential claims in triggers/index.
* fix(nix): update npm dependency hash
* docs(hub): document shared prompts and authority defaults
* fix(server): bound shared-worktree fetch refreshes
* fix(server): preserve buffered git metadata events
* fix(server): cover remote ref selection changes
* fix(server): preserve narrow fetch echo routing
* fix(server): preserve ref updates during refresh
Keep normal refresh requeue semantics for watcher events that arrive while a ref-derived calculation is active, so a second same-ref movement cannot be mistaken for the original fetch echo.
* fix(server): narrow external fetch ref updates
Use authoritative fetch boundaries to separate watcher echoes from outside events, and retain remote ref names so packed-to-loose materialization from other Git clients stays narrow without weakening add/delete fallbacks.
Keep the animated ring phase-locked across hosts and derive its knockout surface from each host's actual interaction state. Align filled status circles at 6px while preserving the larger needs-input alert.
Co-authored-by: Christoph Leiter <christoph@leiter.io>
Explicit inline-code line height creates oversized painted backgrounds when mixed markdown wraps on native. Let native font metrics determine the span height so adjacent lines remain visible.
* fix(desktop): treat unpublished updates as unavailable
GitHub releases can become visible before their platform updater manifests finish publishing. Treat that specific upstream condition as an unavailable update while preserving Paseo reporting for genuine updater failures.
* refactor(desktop): inject updater runtime dependency
Exercise updater event and rejection behavior through a typed fake at the runtime boundary instead of coordinating a hoisted module singleton in tests.
* fix(desktop): preserve lazy updater initialization
* revert(desktop): drop updater runtime refactor
* fix(desktop): restore direct LAN connections
Desktop 0.3.0 routed remote WebSockets through the Electron main process, causing previously working macOS LAN connections to fail with EHOSTUNREACH. Revert custom direct headers and restore the renderer WebSocket path used before the regression.
* fix(app): replace host connections by identity
Keep one connection per stable ID when TLS or password settings change. This preserves the current-main upsert invariant while the custom-header rollback removes the unrelated transport feature.
Mouse drags start and end inside rendered list items, so the clipboard serializer dropped item semantics even when the visible marker was selected. Treat the marker as explicit selection intent while keeping text-only selections unmarked.
* fix(server): keep watcher failures from stalling the daemon
Native watcher teardown can block the Node main thread when an error overlaps subscription lifecycle work. Reduce lifecycle churn, abandon errored subscriptions into bounded polling, recover only after setup settles, and expose error callback frequency in runtime metrics.
* fix(server): keep watcher recovery bounded
Render and search the shortcut chord that actually fires, including rebinding default-only display tokens such as question mark and index wildcards. Unassigned rows show no empty badge, and named-key overrides use the shared display vocabulary.
Allow users to clear and restore shortcut bindings, persist the explicit unassigned state, and keep shortcut displays and actions aligned with the binding that fires. Includes focused unit coverage and desktop renderer Playwright coverage.
* feat(app): add AMOLED and all four Catppuccin themes
Add an AMOLED theme (pure black background) and the four official
Catppuccin flavors (Latte light, Frappé/Macchiato/Mocha dark) to the
appearance picker. Generalize the dark-theme builder to support a light
surface variant, register the new themes with Unistyles, and patch all
registered theme keys when applying font/syntax appearance settings.
* fix(app): localize Catppuccin + AMOLED theme labels across language packs
The initial theme commit pasted the English brand/flavor labels into every
locale. Localize them following each pack's convention: transliterate
Catppuccin and the coffee-flavor names into Arabic/Russian scripts, use
katakana/hanzi equivalents in ja/zh-CN, and standard loanwords in
fr/es/pt-BR. AMOLED is a universal acronym and stays as-is except where
the pack transliterates (Arabic).
* fix(app): make Latte terminal ANSI colors readable
* test(app): cover appearance theme picker options
* refactor(app): scope theme PR to AMOLED
* refactor(app): rename AMOLED theme to Pure Black
* fix(app): refine Pure black appearance
* test(app): wait for sidebar toggle icon
* test(app): remove hover timing race
---------
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
* fix(app): preserve projects across host switches
New workspace entry context retained only a host-local placement, so switching hosts could replace the selected project with the destination's first project. Hydrate that placement against canonical project identity while keeping exact clone placement separate.
* fix(app): preserve manual project choice during hydration
Keep the raw route placement key as stable selection context while hydrating the routed project to its canonical identity.
* fix(server): archive Hub executions by workspace identity
Hub archive previously depended on backing-specific owner state and archived the agent directly, so local and reused workspaces could survive. Resolve the required workspaceId from the execution agent and let the shared workspace archive service own agent, terminal, and backing teardown.
* fix(server): preserve agent archive through workspace teardown
* test(server): keep Hub regression fixtures workspace-scoped
* fix(hub): own executions through fresh workspaces
Hub executions must own a workspace regardless of backing so archival has one lifecycle boundary for agents, terminals, and checkout cleanup. Retain the retired wire selector for parsing compatibility while removing its ownership semantics.
* fix(server): serialize archive metadata writes
* fix(server): preserve archive during reconciliation
Allow desktop metadata controls to shrink so long host and starting-ref labels truncate without crossing the composer rail. Adds a real-browser regression for the combined long-label case.
* fix(app): make session rename reachable in the mobile sessions dropdown
The per-session actions menu in the mobile tab switcher (a gorhom bottom
sheet) opened as an anchored popover, which on native renders a second RN
Modal stacked on the sheet — the fragile nested-overlay case docs/floating-panels.md
warns about. The actions menu (incl. Rename) never appeared on native, so
sessions could not be renamed from the dropdown.
Give the menu compactMode="sheet" like every other mobile menu in the file,
so it stacks as a gorhom sheet on a gorhom sheet (the supported path). Extract
the accessory into workspace-tab-trailing-accessory.tsx and add a reachability
test that fails if the sheet presentation regresses.
* test(app): cover compact session rename via the tab switcher e2e
Adds a Playwright spec that drives the mobile-viewport sessions dropdown -> per-session actions sheet -> Rename, exercising the real gorhom sheet interaction the fix restores (the desktop rename specs only cover the right-click context menu, and the unit test mocks the menu engine).
---------
Co-authored-by: Klaude <klaude@Klaudes-MacBook-Pro.local>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
* fix(app): make the sidebar resize handle usable by touch
The drag handle was a 10px strip at `right: -5`, so half of it sat outside
the sidebar's bounds. Web hit-testing does not clip to the parent box, but
iOS `hitTest:` and Gesture Handler's Android traversal both do, leaving a
~5px target with no visible affordance — hover is the only cue, and hover
never fires on native.
Split the handle by input capability rather than by platform: a fine pointer
keeps the hairline straddling the border, and touch gets a 24x88 grab target
that sits fully inside the sidebar with a permanent grip. The pan gesture now
requires horizontal intent and fails on vertical drift, so a finger on the
grip still scrolls the list behind it.
`useHasFinePointer` replaces the copy of the same media query that lived in
the review surface.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* refactor(app): simplify touch resize handle styles
* fix(app): reveal touch resize grip while pressed
Keep the touch grip out of sidebar content while idle, then expose it for the full resize gesture lifecycle so it remains visible during an active drag.
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
Restore desktop notification sounds by default, add a persisted Play sound setting, and group notification permission and testing in a dedicated Notifications section.
* fix(server): summarise Claude AskUserQuestion permission notifications
Claude's handlePermissionRequest never set title or description on the
AgentPermissionRequest it emits, so buildPermissionDetails fell through to
serialising request.input. For AskUserQuestion that input is the whole question
object, and the push and in-app notifications both showed truncated JSON.
Summarise the first question the way the OMP and Pi providers already do for
their ask_user permissions: title is the question text, description is the
option labels. request.input is untouched, so the permission UI keeps rendering
the full payload.
Closes#2612
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FWY2Xdowxr562GXFdxPhjZ
* test(server): assert question permissions through the emitted request
Greptile flagged that the suite imported a production helper that was only
exported for the test, which leaves an unnecessary public export behind and
couples the tests to implementation structure.
The helper is module-private again, and every case now asserts on the
permission request the session emits and the notification body built from it.
Added a case pinning request.input so the permission UI payload stays covered,
and one for a non-question tool.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FWY2Xdowxr562GXFdxPhjZ
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Refresh expired Kimi usage tokens
* Test Kimi usage token refresh
* Harden Kimi usage token refresh
Follow-up review of the refresh path added in f6ce001a.
Credential reads no longer depend on the shape of fields this provider
only writes back: declaring expires_at and friends as required numbers
meant a null or stringified value threw away the access token sitting
next to them, turning a working Kimi quota bar into "unavailable" with
no way back. Those fields are nullish and coerced now.
Refreshed tokens are merged into a re-read of the credential file rather
than the copy the request started with, so a write by Kimi Code during
the refresh round-trip survives, and a file deleted in that window stays
deleted instead of being resurrected with fresh credentials. A failed
write logs at warn: Kimi rotates the refresh token, so losing that write
silently logs the user out of Kimi Code.
Only 401 triggers a refresh. A 403 means authenticated but not entitled,
which no refresh fixes, and refreshing anyway rotated the user's refresh
token on every poll.
Also: floor expires_at to whole seconds, indent the file the way Kimi
Code does, derive the temp file name from the real basename, and split
out fetchUsageResponse, which is what puts fetchUsage back under the
complexity limit — the original passed neither lint nor the formatter.
* Keep CLI-rotated Kimi credentials during refresh
The Kimi CLI shares the credential file the quota fetcher writes. A refresh
read the file, merged its new tokens, and renamed over it, so a rotation that
landed while the refresh was in flight was replaced with stale tokens and the
CLI's session broke until credentials were recreated.
Thread the refresh token that was actually spent into the persistence step and
skip the write when the file no longer carries it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(kimi): normalize additional usage windows
* fix(kimi): preserve custom auth while parsing limit windows
* test(kimi): cover additional usage windows
* test(kimi): move usage-window tests into service.test.ts
Every other quota provider is tested in service.test.ts; kimi.test.ts
was the only provider with a standalone collocated test file.
* fix(kimi): resolve reasoning-effort options per model, not per catalog probe
deriveModelDefinitionsFromACP derived thinkingOptions once from whichever
model happened to be active in the one-off catalog probe session, then
copied that same array onto every model. Kimi's ACP adapter only reports
thinking levels for the currently selected model, so K2.7/K2.7-coding-fast
(boolean on/off) and K3 (off/low/medium/high) ended up with identical,
often-wrong options depending on which model the probe defaulted to.
fetchCatalog now reuses the single probe session to switch through each
candidate model and read back its real thought_level options, falling
back to the default options if an individual model's probe fails. Skips
entirely when there's one model or no thinking picker, so providers
without per-model effort levels pay no extra round trips.
* refactor(kimi): merge kimi-usage.ts back into kimi.ts
kimi.ts was the only quota provider split across two files; every
other provider (including claude.ts at 503 lines, larger than the
merged Kimi file) stays in one. Also fixes a nested-ternary lint
error surfaced by the merge.
* fix(kimi): re-validate credentials immediately before the rename
Greptile flagged that saveRefreshedCredentials read the credential file,
checked its refresh token against a stale snapshot, then did async
writeFile work before an unconditional rename — leaving a window where
the Kimi CLI could rotate the file after the check and have its newer
tokens clobbered by our stale merge. The refresh-token-only comparison
also missed a CLI access-token rotation that kept the same refresh token.
Re-read the file immediately before the rename and compare both tokens
against the snapshot the merge was built from, discarding the merge if
anything changed. Narrows the check-then-write gap to the smallest
window possible without cross-process file locking.
* refactor(acp): make per-model catalog probing an injectable resolver
Catalog discovery in ACPAgentClient.fetchCatalog ran sequential
setSessionConfigOption model switches for every multi-model ACP with a
thought_level picker, so a slow or nonconforming agent could stall any
provider's catalog probe under the shared 60s timeout.
Replace the built-in resolvePerModelThinkingOptions with an optional
ACPCatalogModelResolver injected through ACPAgentClientOptions (threaded
through GenericACPAgentClient like the existing transformer/parser deps).
The base client ships no resolver, so catalog discovery derives models
from the initial session response and never switches models. Export the
config-option helpers a resolver needs.
The base regression test pins that a plain ACPAgentClient with multiple
models and a thinking picker never calls setSessionConfigOption.
* feat(kimi): resolve per-model thinking options via KimiACPAgentClient
Kimi exposes different thinking-effort levels per model but only reports
the currently selected model's levels, so the catalog probe must switch
through each model to read them back. Move that logic off the shared ACP
adapter and onto a Kimi-specific client.
Add resolveKimiCatalogModels (the sequential setSessionConfigOption probe,
fallback, and warning) and KimiACPAgentClient, which injects it via the
new catalogModelResolver dependency. Select it for providerId "kimi" in
the registry alongside cursor/kiro/traecli, so only Kimi pays the extra
round trips; every other ACP provider skips model switching entirely.
Tests cover the four probe behaviors on KimiACPAgentClient and the
registry dispatch to it.
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
The root error fallback was one unbounded scroller, so on a phone the title
rendered under the status bar and a long stack trace pushed Retry off the
bottom of the screen — the one action that recovers the app was unreachable
on exactly the errors that need it most.
The screen is now a fixed shell: a safe-area message block, an independently
scrolling details region, and a Retry that stays put. `SafeAreaProvider` moved
above the boundary in the root tree because the fallback renders when the app
tree below it has already failed, and insets have to be available there.
The hand-rolled Pressable and bordered message box are replaced by the shared
`<Button>` and `<ScrollableCodeSurface>`, which gives the trace a monospace
face and horizontal scrolling instead of wrapping mid-token. On desktop the
trace is capped and the action sits with the message; a button pinned to the
bottom of a tall window reads as detached from the content it belongs to.
Dropped the "Something went wrong" kicker — it restated the title one line
above it.
Clicking a file path an agent mentioned failed with "No file found"
whenever git ignored the file, which covers anything written under a
scratch directory such as .dev/.
Backticked paths do not open directly. They resolve through the
directory-suggestions RPC, which the daemon runs with gitignore
filtering on for every workspace search. findExactEntry resolved the
path, confirmed it stayed inside the workspace root, and then discarded
it on the ignore check.
Ignore filtering belongs to discovery. It keeps build output out of the
composer's @-mention picker and out of the directory chooser, and both
of those keep it. It does not belong to retrieval of a path the caller
named exactly, where containment inside the root is the only question
left. That check is now dropped from the exact-path branch alone.
This does not widen what is readable. The file-read path applies no
ignore filtering at all; resolveScopedPath checks only root containment,
before and after resolving symlinks. Every file this surfaces was
already readable through the file explorer.
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
* fix(codex): omit disabled skills from slash commands
Codex skills/list returns disabled skills with enabled:false. Filter
them out in loadSkills so Paseo matches Codex CLI/TUI and no longer
surfaces stale slash entries for skills the user disabled.
Fixes#2617
* fix(codex): preserve empty skill discovery
---------
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
* feat(providers): add native options and exact MCP grants
Keep provider policy in provider-owned schemas so unattended Hub runs can grant only requested tools from injected MCP servers. Preserve the native resolved sandbox policy when a workflow override is present, including configured writable roots.
* test(hub): verify real provider tool policy
* test(app): avoid pinning transient restart status
* feat(cli): add durable Hub login
Keep organization-scoped human credentials separate from daemon relationship authority. Connect now exchanges explicit or stored CLI authority for a one-time enrollment token, while logout removes human access without implicitly disconnecting automation.
* fix(cli): require TLS for remote Hub credentials
Durable Hub credentials are bearer secrets, so remote origins must not receive them over cleartext HTTP. Keep literal loopback HTTP available for local Hub development.
* fix(cli): make Hub destinations explicit
Resolve optional Hub origins through one shared precedence policy so active logins remain the default context and credentials stay origin-bound. Report the selected destination before human-facing work, keep structured output auditable, and preserve login authority until requested daemon disconnection succeeds.
Make the release document the single source of truth. ACP drift is explicit opt-in, release preparation uses one remote batch, and a heartbeat owns completion through published artifacts and store submissions. Preserve one changelog entry per beta and collapse the series on promotion.
History was a date-grouped list you scrolled. Finding a session from last
week meant eyeballing every row. This adds a search field to the existing
filter rail, next to the host picker.
Search runs on the daemon, not the client. The daemon already holds every
persisted agent in memory, so it ranks the whole history and the client
never has to warn that it only looked at the pages it had loaded.
A searched response is one ranked page with no cursor. The global top K is
always contained in the union of each host's own top K, so merging per-host
pages and slicing back to the limit is the exact global answer — no k-way
merge, and no offset into a ranking that is recomputed per request, which
would duplicate and skip rows as history changes underneath it. Past result
200 of a relevance order the answer is a narrower query, so the footer says
that instead of offering a "Load more" that cannot deliver.
Matching is the app's existing picker matcher, moved to the protocol package
so app and daemon share one implementation instead of drifting into two
"why didn't this show up?" rule sets. Typo tolerance is opt-in, so the
comboboxes keep exact narrowing; the budget is per token, and four-character
tokens get transpositions only, because at that length a free substitution
turns "main" into "mail", "maid" and "rain".
Ranked rows carry the spans they matched on, so results can mark them. The
subsequence and typo tiers match characters the eye would never find
unaided, and an unexplained row reads as a bug. The ranker computes the
spans anyway; sending them keeps the client from deriving a second opinion
that could disagree with the ranking it is explaining.
Hosts that fail or are offline are now named above the list. Without that,
"No sessions match" is a claim the app has no basis for.
Gated on server_info.features.agentHistorySearch, checked once against the
connected hosts. Reachability is reported by the banner instead, so one
offline host in the registry no longer hides the field.
* fix(pi): stream assistant deltas when pi omits the cumulative message
pi 0.84.0 removed the cumulative `message` snapshot from JSON/RPC
`message_update` events, so `handleMessageUpdate` dereferenced
`undefined` and took down the whole daemon on the first assistant delta.
Every prompt to a pi agent crash-looped the worker.
`message_update` carries an `assistantMessageEvent`, which pi only emits
for assistant messages, so a missing snapshot is treated as an assistant
update. `message_start` still carries the message, so the response id
keeps flowing through `activeAssistantMessageId`.
Fixes#2940Fixes#2946Fixes#2950
* fix(pi): give the message_update COMPAT tag a version and removal date
Follows docs/protocol-compatibility.md: every shim tag needs a name, a
version, and a removal condition or date so `rg "COMPAT\("` stays a
schedulable cleanup backlog.
---------
Co-authored-by: dundunHa <dundunhade@gmail.com>
The app and host version lines ran as one sentence, so the versions ragged
with the length of each name and were hard to compare. Menu hints now take an
optional trailing cell and lay out as key/value across the row.
Renaming the string to a plain name drops its interpolation, so every locale
carries the same untranslated product name it already had.
MenuHint renders in one place today (this menu), so the row layout in the
shared component changes nothing else.
The row only renders under its project header, so stepping it in reads as
part of that project rather than as a peer of it. Extra padding rather than
margin keeps the hover and pressed fills on the same box as every other
sidebar row.
* fix(server): make workspace archive self-healing
Background setup could keep writing after its workspace record was archived, causing disk cleanup to fail and the initiating client to restore a local ghost. Track setup per daemon so archive can stop it, and let repeated archive requests finish residual cleanup after the record has committed.
* fix(server): await setup process termination
Archive must not proceed from setup cancellation until the process-tree terminator has completed. Join that promise to the streamed command's existing finish path so child close cannot release workspace cleanup early.
Hidden optional rows reserved space in the middle of the compact form. Move those spacers below the visible controls so worktree options push the launch selector down without separating it from the other selectors.
Use the standard browser WebSocket constructor without the Node/React Native options object, while retaining the options-capable factory for supported runtimes and the desktop bridge.
Selections contained inside formatting copy only their visible content. Preserve Markdown syntax when the range includes or crosses the semantic element, while retaining list and table structure needed to serialize child boundaries.
Flattened child list lines were reparented inside the parent item line, so rich paste targets split markers from their content. Keep every flattened line as a sibling and cover cross-structure selection plus inline-code double-click copying.
* feat(cli): add Hub configuration deploy
Deploy configuration bytes directly to an explicitly targeted Hub project using only caller-supplied origin and organization API key authority.
* refactor(cli): name Hub deploy contracts
* docs(hub): complete deploy CLI guidance
* docs(hub): link deployed API references
* feat(cli): bundle referenced Hub partials
Send exact referenced prompt-partial content alongside YAML so Hub can resolve local includes while inline-only configurations keep the existing request shape.
* fix(cli): match Hub YAML size semantics
Apply the finalized Hub request limit to decoded YAML length and cover multibyte configuration payloads without changing the wire body.
Intrinsic image dimensions can arrive after the timeline has painted, outside the React segment-change path. Let the browser preserve the visible content anchor during that resize instead of exposing a frame before the deferred bottom correction.
* feat(app): launch a terminal from the New workspace screen
New workspace only ever started a chat agent. Empty workspaces existed but
were reachable only by submitting an empty composer, which nothing advertised,
and terminals could not be started from this screen at all. People concluded
Paseo did not support terminals.
Adds a launch chip to the meta row, shaped from the same ComboboxTrigger as
its neighbours, that chooses between Chat and a terminal. Terminal profiles
mark where a typed prompt goes with a {{{prompt}}} sentinel in their command
or args, substituted client-side before create_terminal_request, so the daemon
learns nothing new and the protocol is untouched. A profile without the
sentinel takes no input: the composer goes read-only and shows the exact
command, so there is nothing to type wrong.
Prompt forms come from each CLI's own --help. claude, codex, and pi take a
trailing positional; opencode's positional is the project directory, so it
uses --prompt=. Profiles predating the sentinel adopt the right form on read,
keyed on the command's base name rather than the profile id, because profiles
created in settings get generated ids.
* fix(app): stop existing launchers passing the prompt sentinel literally
Adoption made every caller of resolveTerminalProfiles see sentinel-bearing
args, but only New workspace substituted them. Pinned targets and the
workspace terminal menu forwarded profile.args straight through, so launching
Claude from an existing workspace ran `claude "{{{prompt}}}"` with that text as
the prompt.
Every launcher now goes through resolveTerminalProfileLaunch. Surfaces with
nowhere to type pass an empty prompt, which drops the prompt-only arg and
gives back the bare command they had before.
Also from the same review:
- Adoption skipped when args lead with a subcommand. `opencode run` takes a
positional message rather than --prompt, and `codex login` takes no prompt at
all. Only the first arg is checked, so `pi --thinking high` still adopts.
- Settings edits the persisted list again, not the adopted view. It writes the
whole list back on any change, so resolving first baked read-time adoption
into the user's config the moment they reordered a row.
- A hidden draft no longer names a workspace. Text typed for a prompt-taking
profile stayed in state after switching to a read-only one and still seeded
the generated title and branch.
- Windows .cmd escaping now has a round-trip test: a real cmd.exe runs a real
.cmd shim that echoes its argv, covering metacharacters, an injection
attempt, and newlines. The existing tests only asserted the command line we
generate.
- Fixed an ENOTEMPTY flake in the terminal suite. kill() only signals, so the
shell was still writing into $ZDOTDIR while its directory was being removed.
Cleanup waits for exit instead.
* fix(app): keep the host and isolation accessible names stable
Wiring the new tooltips also replaced these two accessible names with the
tooltip copy, which broke `sidebar-project-grouping` at the host picker: its
selector is `getByRole("button", { name: "Host", exact: true })` and the button
had become "Choose the host". It timed out on both attempts in CI.
An accessible name identifies a control; a tooltip explains it. Conflating them
renamed a control that other surfaces address by name. The names go back to
what they were and the tooltips keep the "Choose the ..." copy.
* test(server): stop temp-dir teardown failing unrelated terminal tests
The ENOTEMPTY flake survived both earlier attempts. Waiting for the session to
exit shrank the window; retrying the remove did not close it either. The writes
come from processes zsh forks on the way out, which outlive the pty and answer
to nothing the test holds.
Teardown is now best-effort and logs what it left behind. A temp directory we
cannot remove is not a defect in the terminal code under test, and failing on it
reports the race against whichever assertion ran last, which is exactly how this
surfaced: as a flake in an unrelated zsh title test. 10 consecutive runs green.
Also replaces a cast in the Windows .cmd helper with a real narrow, so a change
to ResolvedTerminalCommand fails loudly there. Test files are excluded from
typecheck (tsconfig.server.json), so a cast in one is never checked at all.
* fix(app): keep terminal launch policy in its owning workflows
Profile adoption now parses known global option values before deciding whether argv already contains a positional. Workspace naming is derived inside the terminal workflow so hidden launch-only drafts cannot affect branch or title generation.
* fix(server): preserve cmd shim arguments through both parses
npm command shims forward %*, so metacharacters lose one escape layer before the target process receives argv. Exercise the production node-pty string command line against a forwarding shim, and only tolerate the known ENOTEMPTY teardown race.
* fix(server): stabilize Windows subprocess tests
* test(server): isolate Windows argv recorder output
* test(server): allow native watcher integration to settle
The Claude model manifest declares fast-mode support per model, and the
`claude-opus-5` entry was added without `supportsFastMode`. Claude Code
supports fast mode on Opus 5, so the composer and Command Center hid a
toggle the model actually has.
Set the flag. Everything downstream already keys off it:
`claudeManifestModelSupportsFastMode` gates `buildClaudeFeatures`, which
feeds `listFeatures`, the session `features` getter, `setFeature`, and
the `setModel` auto-clear.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* docs(hub): document required output contracts
* docs(hub): link faq to output contract
* docs(hub): document materializable required outputs
Explain that required declarations resolve through registered capabilities, failed deliveries can be retried, and command-line repository comments use the scoped credential path.
Hub configuration resolves a normalized friendly slug to an immutable daemon ID. Document that model consistently so enrollment terminology matches hub.yml.
* fix(terminal): clear activity when turns are interrupted
Some terminal agents do not emit a stop lifecycle hook after a user interrupt. Treat exact interrupt key frames as cancellation at the terminal input boundary so running state cannot stick, and clear rather than finish to avoid completion attention.
* test(terminal): use platform shell in interruption tests
Let the terminal test fixture select the platform default shell so the interruption cases reach their assertions on Windows.
* feat(server): show provider workflows in subagent track
Normalize workflow tasks into the provider-subagent contract so shared clients need no workflow awareness. Persisted nonterminal runs replay as failed because their original runtime can no longer deliver a terminal update.
* fix(server): preserve workflow replay and tool cards
* fix(server): suppress workflow notification cards
* fix(server): replay workflow child timelines
* fix(server): fail tasks when Claude exits
* fix(server): preserve workflow replay parity
* fix(server): fail tasks on Claude restart
* fix(server): hide workflow child prompts on replay
* fix: terminalize workflows on session reload
* fix(server): own workflow notification routing
* feat(server): show workflow results in detail pane
* fix(server): preserve unrestored task notifications
* fix(server): normalize replay task notifications
* fix(app): debounce reconnect status toast
Keep reconnect transport immediate while delaying the visible status toast so brief outages stay quiet. Scope the toast to the active retained panel, keep its copy and status dot consistent across locales, and add a manual real relay deployment journey that verifies streaming recovery without user action.
* fix(app): isolate manual relay deployment check
Keep the deployment journey out of the provider-wide real test project so it runs only when its local relay prerequisites are explicitly requested. Also keep the relay teardown active when packaged daemon startup or later cleanup fails.
* fix(app): preserve reconnect toast across tabs
Keep reconnect presentation timing at the host level so switching between retained panels does not restart the debounce or flash the toast. Add a browser regression covering a visible outage across retained tab switches.
* fix(app): anchor reconnect debounce to host status
Record host connection status transitions independently of mounted agent panels and preserve the outage start across reconnecting states. Use that host-level timestamp when a visible agent panel appears, with runtime coverage for the transition lifecycle.
* fix(app): reset reconnect toast state per outage
* test(app): build relay journey prerequisites
* fix(app): keep line breaks and indentation in copied code
#2930 strips the pre/code tags from a partly selected code block, which
is right for the Markdown syntax but leaves the code itself going
through Turndown as prose. Turndown collapses whitespace, so two
selected lines arrive as one, the indentation is gone, and
Markdown-significant characters come back escaped.
Divert a partial code selection before that: copy the selected text
verbatim. Fully selected regions are untouched and still produce a
fence, which is what hasSelectedAllContents was already deciding.
The html half needs pre for anything multi-line -- HTML collapses
newlines too, so a rich paste target would lose the lines the same way.
A single line stays undecorated, matching the rule that a partial
selection does not carry its container's formatting.
Two trailing-newline fixes ride along, since both make a paste run code
nobody asked to run. Newlines between rendered lines are their own text
nodes, so a selection that overshoots the end of a line carries one;
strip it. The block's Copy button was handed the raw fence body, which
ends in a newline; hand it the rendered text instead.
* fix(app): strip every trailing blank line from copied code
The Copy button stripped one trailing newline, so a fence whose author
left a blank line before the closing delimiter still put an executable
newline on the clipboard. Pasting that into a terminal runs the last
line, which is the thing the strip existed to prevent.
Both copy paths now share one regex in markup.ts and remove every
trailing break, plus any indentation that followed the last one.
Reported by Greptile on #2935.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(app): infer code copy format from selection boundary
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
Retained tabs stay mounted under display:none while inactive, which
reports a zero-width onLayout. ControlledAgentControls fed that
straight into its density resolver, so a hidden tab's toolbar
collapsed to its tightest density and painted one frame collapsed
before re-expanding when the tab became visible again.
Extract the zero-width guard that useContainerWidthBelow already had
into a shared readMeasuredWidth helper, and use it in the composer
toolbar's own layout handler so a display:none measurement can never
drive its density.
Also bump the chat outline rail's MIN_PANEL_WIDTH breakpoint (818 ->
918) so the rail disappears earlier under a narrower panel, and widen
the chat-outline e2e spec's viewport so its panel keeps healthy margin
over the new threshold.
* fix(app): respect assistant copy selection boundaries
Selection serialization reintroduced semantic ancestors even when the user selected only their inner text, causing list markers and code delimiters to expand beyond the visible range. Preserve semantics only for complete selected nodes, and keep rich list payloads compatible with text composers that prefer HTML.
* fix(app): preserve loose list copy breaks
* feat(opencode): fill subagent descriptor with task, type, model, and tokens
OpenCode subagent rows rendered with no information: the initial upsert
carried only the child session title and status, so the track showed a
bare title with an empty second line and the pane tab fell back to
"Subagent".
Fill the same provider-agnostic descriptor fields Claude populates since
#2498, from data OpenCode already emits:
- description <- the task tool call's description input (fallback: the
child session title), so the task names the row
- title <- subagent_type from the task call, else the session's agent
- toolCallId <- the parent task tool call id
- subtitle <- "agent · model · Variant · Nk tokens", folded from the
child's own session record and assistant frames, deduped per child
Facts are never inherited from the parent; absent facts render as
nothing. Presentation upserts omit status so a late subtitle update
cannot revert a finished child (the store's sticky contract). Both
task-link orderings are handled, and the hydration path derives the
same facts for historical children so restored sessions match live.
Server-side only; no protocol or app changes.
* fix(opencode): emit subagent type as descriptor title from assistant frames
* feat(app): show subagent info line in the provider subagent pane
* polish: address subagent presentation review nits
* fix(opencode): link subagents from task metadata
Current OpenCode task results identify their child session in tool metadata rather than the legacy textual output. Preserve that boundary field so parallel task calls retain the correct description, type, and tool-call association.
---------
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
CSS accepts transform-origin keyword pairs in either order, but React
Native parses them positionally (x first). The vertical-first string
crashed anchored menu popovers on native wide form factors with
"Transform-origin left can only be used for x-position".
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(server): report Claude runtime death instead of sitting idle
When the Claude Code CLI process dies on its own — crash, OOM kill, host
suspend — nothing in the daemon observes it unless a turn happens to be in
flight. The query pump only reports a failure while it is draining a turn, so
an idle agent's stream just ends: the agent stays `idle`, indistinguishable
from a healthy resumable one.
Everything the agent parked inside that process dies with it. Background Bash
shells, `Monitor` watches and workflows all live in the CLI process, so the
completion notification that would have woken the agent back up never arrives.
The work is silently gone and the user finds out much later, if at all.
The stale handles are never dropped either, so writes keep going to a dead
transport for as long as the daemon lives (#2472).
Watch the spawned child at the single seam it is already handed over
(`onChildProcess`). When it exits between turns, drop the dead query/input
handles so the next write spawns a fresh process, and emit `turn_failed` —
the manager already routes a non-foreground `turn_failed` to `error` plus a
timeline entry.
A death mid-turn is left alone. The pump already waits for stderr to flush and
reports the real cause; reporting here first would replace that with a bare
exit code.
Intentional teardown stays quiet. All three sites that retire a process detach
the child handle first: `close()` sets `closed`, and `ensureQuery()` and
`handleMissingResumedConversation()` null `childProcess` before ending stdin.
Fixes#2472
* fix(server): tree-kill the retired process on missing-resume recovery
The missing-resumed-conversation path ends the child's stdin but never
tree-killed it, so MCP children of the retired claude process could outlive
it as orphans. Every other teardown path already does this; do it here too.
Reported by Greptile on #2910.
* fix(desktop): preserve browser tabs across focus changes
Electron can replace a guest WebContents when a webview is reparented, which reloads the page and can race automation against a destroyed target. Keep each browser guest on one permanent paintable surface and make user and agent resizing share one canonical viewport.
* fix(desktop): recover browser surfaces after lifecycle changes
* fix(desktop): preserve resident browser lifecycle state
* fix(desktop): seed inactive browser viewport
The running dot no longer animates, and the machinery behind it is gone
rather than switched off: the shared refcounted clock, the theme-scheme prop
mapping, and the extra wave color all existed only to drive it.
Four affordances were tried and each failed for its own reason, which is
worth writing down before someone reaches for one of them again. Fading the
dot blends it toward the row's surface rather than toward black, so the
bottom of every cycle was a washed-out blue that read as the success dot one
row up. Dropping chroma holds brightness but turns the low point grey.
Raising chroma has nowhere to go: blue at these lightnesses is already within
11% of the sRGB gamut edge, so the only real headroom is at the hue-250 cusp,
which costs lightness. An expanding ring solved the color problem outright —
motion cannot be confused with another hue — but stayed too subtle at a size
that suits an 8pt dot sitting beside a title.
If the ring is revisited: the project badge shell clips its contents to 12pt,
which is what made the first attempt invisible on exactly the rows most
likely to be on screen. The clip is not what makes the shell read as a
knockout; its fill is.
* fix(server): finish subagents the parent transcript can never end
A skill-spawned subagent — a /code-review run, for example — has no Task
tool_use for the parent to record, so its sidecar carries no toolUseId
and its id appears nowhere in the parent transcript. Replay resolved no
link, emitted no status observation, and the store defaulted the
descriptor to running. The subagents track then reported a running child
on every reopen, days after it had finished, and the archive dialog
offered to stop it.
Paseo only records an outcome for a child whose Task call it parsed out
of the parent, so a child with no Task call is one no reader can finish.
Resolve identity and status together and mark those completed. A child
that does have a Task call but no result yet is the opposite case — the
parent was interrupted mid-Task — and keeps running.
Keying on "no Task call for this id" rather than "no toolUseId" also
covers legacy grandchildren. Across 554 recorded subagent transcripts,
5 stuck rows had no toolUseId and 8 more named a Task call made inside a
sibling's session; none of the 13 has a recoverable tool_result in the
parent.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(server): admit only declared Claude subagents on replay
* test(server): cover Claude workflow subagent accumulation
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
* Release the audio session when voice capture and playback go idle
Opening Paseo permanently paused music playing in other apps. Once the
mic had been used a single time, every subsequent foreground of the app
killed the user's audio again until the app was force-quit.
iOS AudioEngine.setupAudioSession() takes .playAndRecord/.voiceChat — a
non-mixing category — and calls setActive(true), but the only
setActive(false) was reachable from tearDown(). Nothing handed the
session back after a normal dictation turn, so iOS re-asserted the last
category the app set on every foreground. The iOS module had no
app-lifecycle handling at all, while Android already had a lifecycle
listener; that asymmetry is what made the platforms diverge. Android had
the same leak in a different shape: AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE
was requested per recording and never abandoned.
Add releaseAudioSession() to both platforms and call it once capture
stops and the playback queue drains. iOS pauses the engine, calls
setActive(false, .notifyOthersOnDeactivation) — this is what tells the
other app to resume — then drops to .ambient/.mixWithOthers so any
implicit reactivation mixes instead of interrupting. The session is
re-acquired lazily on toggleRecording(true)/playPCMData, so voice keeps
working; resumeRecordingAndPlayer() re-activates before restarting the
engine, which keeps post-interruption resume intact. Releasing is
guarded on isRecording/isPlaying, and checkEngineIsRunning() now refuses
to start the engine while the session is released, since starting it
would silently re-take the session.
The JS wrapper probes the native module before calling, because a JS
bundle shipped over EAS Update can land on a binary predating this
function.
* Correct the iOS signing diagnosis in mobile-testing docs
The "No Accounts" error does not mean no Apple ID is configured. The
account can be registered in Xcode while its Xcode-Token keychain item is
missing, which Xcode reports as No Accounts and then compounds by falling
back to a stale cached profile — surfacing as a certificate mismatch that
sends you chasing the wrong thing.
Document the DVTDeveloperAccountManager log line that names the real
cause, the two commands that check what is actually configured, and the
build-unsigned-then-sign-manually stopgap.
* Reacquire Android audio focus when playback restarts
releaseAudioSession() abandons the AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE
request and pauses the AudioTrack once capture and playback go idle, but
nothing took either back. The next playback turn therefore ran with no
focus held, so the assistant's response competed with whatever the user
was listening to instead of interrupting it.
Add acquireAudioSessionIfNeeded(), the mirror of the iOS
activateAudioSessionIfNeeded() already called from playPCMData, and use
it from both playPCMData() and resumePlayback(). A null audioFocusRequest
is what "we released" looks like, so it doubles as the guard against
re-requesting focus mid-turn.
Reported by Greptile on #2866.
* Make Android playback focus reacquisition non-fatal
The previous commit dropped PCM data when focus reacquisition did not
return AUDIOFOCUS_REQUEST_GRANTED. That is worse than playing unfocused:
playPCMData is fire-and-forget from JS, and playAudio() settles on a
duration timer rather than a native ack, so the runtime acknowledged and
discarded a chunk that was never queued.
It also broke delayed focus. The request is built with
setAcceptsDelayedFocusGain(true), so AUDIOFOCUS_REQUEST_DELAYED is a
normal outcome, but routing it through handleAudioFocusBlocked() abandoned
the pending request and the later grant could never arrive.
Request focus, then play regardless of the result, and leave the request
outstanding so a delayed grant still lands via the listener.
Reported by Greptile on #2866.
* Track scheduled buffers so iOS actually releases after playback
releaseAudioSession() guarded on !speechPlayer.isPlaying, but
AVAudioPlayerNode.isPlaying stays true from play() until an explicit
stop()/pause() — draining every scheduled buffer does not clear it. The
normal completion path is processQueue() -> releaseSessionIfIdle(), and
stopPlayback() is only reached from the explicit stop() API, so after the
first spoken response the guard blocked every release and the session
stayed active. The music-pausing bug returned in full for anyone using
voice responses.
Count buffers that are scheduled but not yet rendered, decrementing from
the .dataPlayedBack completion handler, and guard on that instead. The
handler runs on an AVAudioEngine-internal thread, so the count is behind a
lock. Both stop() paths reset it, since stop() discards what is scheduled
and a stale count would block every later release.
Found by Codex review on #2866.
* Stop re-taking Android audio focus on every foreground
ExpoTwoWayAudioLifeCycleListener calls resumeRecordingAndPlayer() from
onResume, and that requested AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE before
looking at isRecordingBeforePause. Foregrounding the app while nothing was
recording therefore took exclusive focus back and re-paused the user's
music — the same "every foreground kills my music" symptom this change set
exists to remove, just reached through the Android lifecycle instead of the
iOS category.
Return early unless capture or playback was actually live when we
backgrounded.
Found by Codex review on #2866.
* Add Korean UI localization
Refreshes the Korean translation from #1544 against the current client locale surface and adds cross-platform locale detection coverage.\n\nCo-authored-by: JinHyeok Jeong <150323322+Kesta-bos@users.noreply.github.com>
* fix(i18n): preserve reviewed Korean status labels
Restore the wording corrections contributed in the original Korean localization work and lock them with exact-copy assertions.
Co-authored-by: JinHyeok Jeong <150323322+Kesta-bos@users.noreply.github.com>
---------
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
Co-authored-by: JinHyeok Jeong <150323322+Kesta-bos@users.noreply.github.com>
Pi left task and direct subagent tool calls in the unknown detail shape, so clients rendered raw request and result payloads. Normalize execution calls at the provider boundary so the existing task card owns their running and terminal states.
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
The badge icon moved from `identityColor` to `identityForeground` when the
status palette was unified, because a stroked glyph on a surface has to clear
contrast against that surface rather than behind white letters. The helper
still expected the fill table, so it read teal as #368080 where the icon now
draws #3e6d6c.
The variant is per-scheme and there is no scheme to read from a Playwright
locator, so the expectation pins "light" and says why.
The sidebar row narrowed to services only and its display-preferences item is
now labelled Services, while the panel and menu that list both types are still
Scripts. That is two words in the UI for things a reader will assume are one,
which is exactly what this file exists to settle.
Checks stopped being a toggle. Showing the icon without its word is a real
third answer rather than half of "off" — the state stays on the row for a
glance and a passing workspace stops paying a word for it on every line — so
it left the boolean row-item record instead of widening it, and a stored
`sidebarRowItems.checks === false` migrates to the new "none".
The services item narrowed to actual services. It used to fall back to any
running command, but a test watcher running says nothing about whether a
workspace is up, and the item now says "Services" so drawing a terminal glyph
under that label was a control lying about itself. It reports the name rather
than the port because the name is what you called it. `health` was already on
the wire and nothing in the sidebar read it, so a failed check now turns the
whole item danger; where several services run, the failing one takes the
single slot, since a healthy one is the unremarkable case and any of them
says "this is up".
That narrowing is why the rename went through the code and the persisted key
rather than stopping at the label: the concept genuinely changed, and a UI
saying "Services" over a `scripts` key is the synonym docs/glossary.md calls a
design error. Both key migrations are tagged COMPAT.
The rest is the sidebar row's geometry. The show-more and new-workspace rows
each carried their own smaller, inset, less-rounded version of a workspace row
— 24pt of left margin, a 14pt icon column, `borderRadius.md`, one fill for
hover and press — which under a column of real rows reads as a mistake rather
than a distinction. They now take the workspace row's geometry, and the
show-more row asks its caller whether it is ending an indented list, because
the two groupings disagree.
Group spacing moved from margin to padding so a collapsed group gives it back
instead of staying spaced for content that is gone. SPACING gained a 0.5 rung
for the 2pt gap between rows.
Three systems were reporting the same states: a full and a muted status
tier in the theme, plus raw palette steps in StatusBadge and
getStatusDotColor. A PR icon was muted in the sidebar and full-strength
in the PR panel, and the Resolved badge used a theme-independent green
so it painted a dark block in light mode. The muted tier is deleted and
every status surface now reads the one token per signal.
Dots keep a band of their own. A dot is a few points of solid colour
with no shape to read and no label attached, and the running one pulses,
so at the status band's lightness it sat below the check icons and host
badge on its own row — backwards, since the dot is the row's state.
Chroma caps at 90% of gamut rather than 100% because at the edge green
reaches C=0.215 while blue manages 0.116 and the set stops reading as
one family; dark lightness caps at 0.72 because red desaturates to pink
above it.
Host badges needed a foreground variant of the identity table. That
table is tuned for a white letter on the colour as a fill; used as text
it cannot clear 4.5:1 on both themes at once, since light needs
luminance <= 0.183 and dark needs >= 0.212. The new set is generated on
the status family's exact lightness and chroma fraction, so a host name
and a CI check in the same meta row carry equal weight instead of the
brighter one winning the row.
Also: PR avatars had their own eight-hex palette and hash duplicating
identity-colors; workspace rows under a project group now indent to the
same rail as status-grouped rows; group headers are muted; and the
pulsing dot is faster and fades further.
Settings lost the workspace host when entering the app-wide route, while desktop bootstrap could restore a removed local connection before the saved registry was ready. Use the remembered workspace host for Settings and only register the managed daemon when its server is unknown after registry hydration.
Beta testers had to find prerelease assets on GitHub by hand. The
download page now carries a Stable/Beta switch, with stable staying the
default and the canonical URL.
The channel lives in the URL (?channel=beta) rather than client state so
maintainers can link someone straight to it. That link outlives the beta
it was shared for, so the switch only renders while the newest
prerelease leads stable on its core version, and a stale ?channel=beta
falls back to stable instead of serving a promoted version twice.
"What's new" deep-links the minor group anchor (/changelog#release-0.3),
not the exact entry: promotion rewrites the -beta.N entry in place, so
that anchor dies while the group survives — and the group is the
stable-to-beta diff a beta downloader came to read. A version missing
from the bundled changelog links plain /changelog rather than a dead
anchor, which covers the window between a tag going public and the site
redeploying.
Homebrew, the app stores, and the web app have no beta, so the Beta view
drops those rows instead of showing inert placeholders.
The mobile terminal stream readiness work made live focus and renderer state recreate the stream controller. A replacement controller could remain untargeted, so input reached the PTY while output stopped reaching the terminal. Keep the controller scoped to the client connection and expose current render state through stable callbacks.
* feat(website): nest docs pages under landable groups
The docs sidebar could already render groups from subdirectories in
public-docs, but nothing used it because every doc was a flat file, and
two things stopped it being usable.
A group had no page of its own, so its label was only a toggle and its
name came from the directory name via formatLabel. A directory's
index.md now supplies the group's label, order, and href, which makes
the group landable and lets the label be authored rather than derived.
Categories and top-level directories named the same thing, so a
categorised doc at hub/index.md produced a group nested inside its own
category. Only directories below the top level become groups now.
Also fixes two behaviours in the sidebar:
- The open/closed effect assigned containsActive on every change, so a
group you expanded to browse collapsed the moment you navigated
anywhere else. Navigating in now opens a group and navigating away
leaves it alone.
- The sidebar scrolls independently of the page and never scrolled the
active entry into view, so loading or refreshing a page far down the
list left the sidebar parked at the top with no visible position.
The group row is a single target: it navigates and expands, and
collapses when clicked again from the page it points at. A separate
chevron button would put two actions a few pixels apart.
* docs(hub): document Hub
Adds a Hub section covering what it is, how it works, daemons,
triggers, configuration, activity, and the two ways to run it.
The section separates using Hub from running it. Overview, quickstart,
daemons, triggers, and configuration hold regardless of who operates
the deployment; the self-hosting branch owns deploying the service and
creating the GitHub, Slack, and Discord apps it talks to. That split
falls on a real seam, because those apps are exactly what a hosted Hub
would own on your behalf. Hosted is a placeholder until it ships.
Each provider gets a trigger page carrying its events, filter
semantics, and the full paseo.event.* reference for the data a prompt
can read. That data is the thing a config author actually needs and
the thing they cannot get from anywhere else.
Every YAML block was parsed against the Hub config schema rather than
eyeballed, which kept several plausible-looking things out of the
docs: worktree prNumber takes a literal number and not a merge
variable, only daemon environments run today, and github.push has no
actor so it can never satisfy the required from_users filter.
Troubleshooting moves to order 90 so it stays last.
* refactor(website): let the active docs entry scroll itself into view
The layout queried the sidebar for the active entry after every
navigation, which coupled it to when that entry mounts. A link inside a
collapsed group only mounts once the group's own effect opens it, so
the query could run against a DOM that does not contain it yet.
Moving the scroll to the entry removes the ordering question rather
than answering it, and drops the ref and the cross-component query.
Reported by Greptile on #2864. The failure it described does not
reproduce: navigating from a page outside a collapsed group into one of
its children still scrolls correctly, verified with the group confirmed
collapsed and the sidebar forced to the top. The coupling was real even
though the bug was not.
* docs(hub): stop listing BETTER_AUTH_URL as required
It falls back to PASEO_CLOUD_PUBLIC_URL, so a deployment that sets the
public origin already has a correct auth base URL. Listing it as
required made the Fly recipe look incomplete when it was not.
Moved to optional with its default, and recorded what actually breaks
without BETTER_AUTH_SECRET: the auth routes close and nobody can sign
in.
Reported by Codex on #2864, which read the same inconsistency the other
way round and proposed adding the variable to the Fly example.
* docs(hub): attach a database in the Fly recipe
The Fly section showed inline config and a secrets command, neither of
which supplied DATABASE_URL. It defaults to localhost:5432, which on
Fly reaches nothing, so the documented deployment could not start.
fly postgres attach sets DATABASE_URL as a secret, so the step is the
attach itself rather than another variable to copy.
Reported by Codex on #2864.
* docs: align Hub self-hosting contract
The archive response already waits for durable archival and owned-worktree cleanup. Assert those final states directly instead of racing a separate filesystem watcher with its own fixed timeout.
Beta tags now reach external testers without per-release dashboard work. Reserve distinct native build slots so successive betas and the promoted stable build can coexist under one app version.
Scale custom glyph paths in measured cell coordinates so box-drawing runs fill their cells on Android. Debounce daemon resize claims at the terminal pane boundary so keyboard layout animation sends only the settled dimensions.
Make provider catalogs compact and content-addressed so repeat launches can reuse the persisted snapshot instead of transferring it again. Remove eager global catalog loading and the stopped-agent defensive tail fetch, while keeping sequence-gap and reconnect recovery as the correctness paths. Add an opt-in production-ID Android profiling build for local startup traces.
Live Markdown block promotion discarded timeline positions, so merging a disjoint history window could sort the final block ahead of earlier blocks. Preserve the source position on every promoted block so timeline merges keep the response intact.
* fix(server): pin workspace file watchers to native backends
Backend auto-detection could select an IPC watcher whose subscription lock deadlocked the Node worker pool during watcher reconfiguration. Select the host-native backend explicitly so filesystem work, logging, and relay DNS cannot be starved by that path.
* test(server): cover native watcher production boundary
Route the existing real filesystem observation test through the same subscription function used by the daemon. This proves native watcher selection and reconfiguration with actual file events instead of only testing the platform selector.
* test(server): synchronize native watcher readiness
Replace fixed startup sleeps with a real tracked-file event barrier. Native backends can resolve their subscription before the initial stream is fully drained, so the integration test now begins assertions only after an event has been delivered and refreshed.
* fix(server): measure native workspace watchers
Route composer attachments through the shared compact menu sheet so headers and rows use the same alignment rails. Base the final-row padding on the device safe area to keep actions clear of system navigation.
The highlight is an overlay inside the pressable rather than a background on
it, because the node Reanimated animates cannot also carry a themed Unistyles
style. An overlay inherits nothing, so a rounded sidebar row flashed square.
Take the corners from the pressable's own style. The row already declares its
shape once, and a highlight that restated it would be a second copy free to
drift; highlightStyle is left meaning only what the highlight is painted with.
Corners apply before it, so a caller can still depart from the row's shape.
The trigger was a vertical ellipsis on compact and a horizontal one elsewhere.
Use the horizontal glyph everywhere: it is the wider shape, and it is the one
sitting above a menu that now presents from the bottom edge.
The button draws at 32pt, under the 44pt touch minimum, and three of them sit
flush beside the title, so the slop can only grow vertically — widening it
would put two buttons' hit areas over the same pixels.
Step the title and project name down one rung of the scale on compact, and put
the host on the project line. On a phone this header is the only thing on
screen that names the machine, because the sidebar carrying that badge is
closed. It follows each host's own badge setting, so a local-only setup stays
quiet, and it yields space before the project name does.
A project name that only repeats the workspace name is still dropped where the
two sit side by side, and kept on compact, where the line exists for the host
anyway. That made shouldShowSubtitle a verdict the header source was not in a
position to reach, so it now reports the fact: isSubtitleDistinct.
Drop the 60% cap on the project name. It resolved against a box whose own width
came from that same text, so it clipped while there was still room beside it;
flexShrink already gives up space only when there is none.
The server glyph, its identity-colour table and the muted label lived inside
the sidebar meta row, and the three stores that decide whether a host has a
badge at all were assembled at one call site. Both are about to have a second
caller in the workspace header.
Move the badge to hosts/host-badge and the resolution to useHostBadges. The
badge now owns its glyph size, and the meta row matches it, since it is the one
item on that line that also appears off it.
Selecting an item parked its action in a ref and waited for the Modal's
onDismiss to flush it. MenuOverlay returns null as soon as the menu closes, so
the Modal unmounted and onDismiss never fired: every close-on-select action was
dropped. Creating a terminal from the compact workspace menu did nothing at all.
Wait on a timer instead. The grace period was always about giving UIKit time to
tear the surface down before a native presenter launches, and a callback fired
from inside a subtree being removed is racing its own removal. The sheet had no
flush at all, so it was broken the same way for a different reason.
Rows are 40pt below md and 28pt above it. The split is on breakpoint rather
than presentation because the compact popover, which compactMode defaults to,
is worked with a thumb just as a sheet is.
Move vertical spacing off the row onto a new MenuPage, which also provides a
page's depth so the popover and the sheet cannot disagree about what a page is.
A row carrying vertical margin would serve both the edge inset and the
inter-row gap at once, and margins do not collapse.
Native font glyph bounds do not reliably fill terminal cells, and long box-drawing runs accumulate advance drift. Paint block and box glyphs from cell geometry while preserving the original Unicode cell model for selection and copying.
Sidebar rows previously waited for JavaScript-driven selection or navigation before showing feedback. Keep the transient highlight on the UI worklet while leaving activation, cancellation, and drag ownership with the existing pressable.
Clearing the hidden terminal input after multi-character commits can leave the keyboard's composing buffer intact, causing later changes to replay committed text. Keep the accumulated native value so subsequent changes emit only their appended suffix.
* fix(app): keep an open workspace menu from unmounting its own trigger
The sidebar kebab is revealed by hover, and on a compact layout the menu it
opens is a sheet that slides up over the row it came from. The row un-hovers,
the trailing overlay unmounts the trigger, and the menu's open state goes with
it — nothing is left that can close the surface already presented in the
portal, so a full-screen backdrop swallows every click until reload.
Lift the menu's open state to the component that decides whether to render the
kebab, and keep the kebab rendered while the menu is up.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(app): follow the sidebar and host badge redesigns in the e2e suite
Three specs were still driving surfaces that moved out from under them, so CI
has been red on main since the sidebar polish landed.
Display preferences became one row per decision with the options a page down,
so grouping and the host filter need that page walked first. Five specs had
their own copy of the two-line dance; it lives in the sidebar helper now, and
the host filter has its own opener next to the menu it belongs to.
The host badge is plain secondary text on the meta line and its identity colour
lives on the server icon alone, so the helpers stop asserting a tinted label
and a pill fill that the redesign removed.
Alignment rails are compared across canvas glyph metrics and SVG bounding
boxes, so the residual depends on the host's font; a 1px and a 0.5px tolerance
both landed on the wrong side of it in CI. Two pixels still catches the whole-
pixel offsets these rails encode. The compact sheet spec also read its rails
while the panel was still sliding in.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* chore(app): let a browser e2e run take a private Metro cache
Metro's on-disk cache is shared by every checkout on the machine. Editing one
app source file rebuilds that module against everyone else's cached output and
the Unistyles styles can come back desynced, which surfaces as alignment specs
failing with real-looking pixel numbers that go away when the edit is reverted.
E2E_METRO_CACHE_VERSION namespaces a run's cache without deleting anyone
else's entries, so the cache can be ruled out before a layout failure is
believed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(app): stop the chat scroll helper from hanging on a stalled sampler
`scrollChatAwayFromBottom` waited on a promise that could only ever resolve
from a `requestAnimationFrame` sampler started by a `wheel` listener. rAF does
not fire while the page is occluded, which is reachable on a CI runner, so the
sampler could stall on its first frame and the wait never returned — the test
died on its own 240s timeout with nothing to read. A missed wheel event did the
same.
Settling the scroll before measuring is an optimization; the assertion after it
is what decides the test. Sample on a timer with a deadline so a stall falls
through to that assertion instead of consuming the test.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(app): remove layout geometry e2e coverage
Rail and pixel-alignment assertions depend on host rendering details and should not define the browser E2E contract. Preserve the surrounding behavior coverage while dropping the Metro cache escape hatch that only supported diagnosing those assertions.
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
Pin the keyboard controller to the release that prevents stale keyboard positioning from leaving the composer underneath a keyboard that is still visible.
* fix(workspaces): default new worktrees to upstream refs
Use the daemon-resolved upstream as the untouched starting ref so unpushed local commits do not leak into new workspaces. Preserve exact base identity for fork remotes while keeping local-first behavior across version drift.
* fix(workspaces): preserve exact base refs in git actions
Resolve accepted display aliases back to the stored ref before comparisons and updates. Fail closed when a non-origin remote base has no explicit mutable merge target.
* fix(workspaces): reject conflicting qualified base refs
Display aliases remain accepted for compatibility, but a qualified ref carries remote identity and must not be canonicalized to a different stored upstream.
* test(server): make upstream fixtures portable
Keep the checkout-status fixture aligned with the optional upstream field, and select main explicitly when cloning a bare test remote whose default HEAD varies by platform.
* fix(workspaces): preserve exact upstream refs
Qualified remote refs now fail closed when pruned instead of switching lineage. Metadata accepts Git-valid upstream names rather than applying a narrower character whitelist.
* fix(workspaces): preserve qualified local base refs
Local tracking branches are exact upstream identities too. Keep heuristics only for legacy unqualified metadata so origin cannot replace a stored refs/heads base.
Replace time-based subscription expiry with a bounded hot set so switching among a small working set and backgrounding the app do not force stale timeline catch-up. Visible chats always retain priority, while disconnect and legacy delivery preserve their existing authoritative boundaries.
* fix(app): preserve chat scroll across workspace switches
Hidden retained panels can emit geometry-driven scroll events while collapsed, which previously looked like user intent and disabled output following. Keep the retained DOM mounted, suspend its inactive scroll work, and only detach from the bottom after explicit input.
* fix(app): classify chat scroll intent at input
A held pointer is not evidence that a geometry-driven scroll was user initiated. Detach output following directly from upward wheel, key, touch, or mouse-drag input, and keep ordinary scroll events responsible only for metrics and bottom reattachment.
* fix(app): keep editing keys out of scroll intent
Question inputs render inside the chat viewport, so their caret-navigation keys bubble through the same keyboard boundary. Treat keys from editable descendants as editing intent while preserving viewport-level keyboard scrolling.
* fix(app): exclude zoom gestures from scroll intent
Ctrl-wheel and trackpad pinch events can carry a negative wheel delta without scrolling chat content. Keep those browser zoom gestures from disabling output following.
* fix(app): require pointer-driven chat movement
Mouse movement alone is not viewport scroll intent. Correlate actual upward scroll with short-lived content-drag evidence or a scrollbar-gutter gesture, while excluding embedded editors and retaining geometry-only following.
* fix(app): recognize overlay chat scrollbars
* fix(app): scope scroll intent to chat viewport
* fix(app): rearm chat history at scroll start
* fix(app): track chat autoscroll intent
* fix(app): separate pointer scroll gestures
Use mobile action sheets and long-press file menus while preserving provider context across sheet portals. Keep sidebar and file-pane glyphs on shared visual rails, avoid scrollbar layout shifts, and hide keyboard affordances where shortcuts are unavailable.
Sidebar polish round.
Menus. `dropdown-menu` and `context-menu` were two copies of one engine that
differed only in how the trigger fires — identical from `computePosition` down —
and neither could nest. Both are now thin wrappers over
`components/ui/menu/`, which owns anchoring, a pure page-stack navigation model,
and one presentation fork: flyout on pointer, pushed pages in a sheet on compact.
All 31 call sites are unchanged. Items got a rounded inset fill, a visible
separator, and `selected` (a check) split from `active` (a fill).
Sidebar display preferences. The flat wall of four sections becomes one row per
decision with its value, and per-feature toggles over what a workspace row shows:
host, pull request, checks, scripts. Each is independent — a ticked toggle that
draws nothing is a lie. `display-preferences/` hides that grouping lives in
zustand while title source and row items are app settings.
Timestamps. "Last activity" now ticks itself. The label rounds to the minute and
never shows seconds; past a week it becomes a date. The timer lives in the leaf
`<Text>`, at most one per resolution tier shared by every row, and a tick only
re-renders when the string actually changed — so a memoized row that legitimately
never re-renders still stays current.
Colour. Full status tier regenerated at 90% of gamut max (was 85), same rule as
the muted tier, lightness and hue untouched. File icons are pulled toward neutral
in OKLab behind `getFileIconSvg` — material-icon-theme picks its palette for a
tree where the icon is the only colour on the row, and ours is not. New/deleted
files use the forge square-plus/square-minus glyphs at muted weight instead of
tinted pills. Project icons round a little more; uploaded icons never do.
The project status badge is a knockout again: it fills with the colour of the row
behind it, so the ring reads as a gap in the icon rather than a white halo.
File rows. The always-visible kebab is gone from the file explorer and diff tree
in favour of right-click, which also reclaims the reserved action slot.
* fix(app): stabilize native terminal keyboard input
Terminal focus requests blurred and remounted the hidden input even when the software keyboard was already visible, causing visible IME cycles and leaving clipboard commits coupled to stale native text. Keep focus requests idempotent while resetting only the native input buffer.
* test(app): remove misleading keyboard device scenario
Agent Device cannot durably assert software-keyboard continuity or commit a keyboard clipboard suggestion, so the scenario overstated coverage already owned by focused input-policy tests.
* fix(app): derive terminal keyboard focus from visibility
Use the keyboard-controller inset as the source of truth so a system-dismissed keyboard cannot leave focus activation suppressed. Avoid a duplicate focus request while explicitly showing the keyboard.
* fix(app): track terminal keyboard visibility across layouts
Keep keyboard visibility independent from compact-only terminal padding so tablet taps remain idempotent while the software keyboard is open.
2026-08-03 18:32:16 +02:00
1964 changed files with 179700 additions and 40292 deletions
description:Cut a beta release of Paseo. Use when the user says "release beta", "cut a beta", "ship a beta", "beta release", or "/release-beta". Betas are release candidates on the beta channel — they carry an in-place changelog entry, don't move the website download target, and publish npm only on the beta dist-tag.
description:Cut a beta release of Paseo. Use when the user says "release beta", "cut a beta", "ship a beta", "beta release", or "/release-beta".
user-invocable:true
---
# Release beta
Read `docs/release.md` in the Paseo repo and follow the **Beta flow**section end-to-end. Run the**Beta release** completion checklist at the bottom of that doc.
During preparation, classify the previous-stable-to-`HEAD` diff as patch or minor and show the target version and rationale to the user. Agents never select a major version autonomously.
Each beta updates an in-place `CHANGELOG.md` entry (`## X.Y.Z-beta.N`) that gets overwritten at promotion, and npm publishes only on the explicit `beta` dist-tag.
Follow the **Beta flow**and**Beta release** completion checklist in `docs/release.md` end-to-end.
Read `docs/release.md` in the Paseo repo and follow the **Standard release (stable)** flow if cutting fresh, or the **Beta flow** promotion step if promoting an existing beta. Run the **Stable release (or promotion)** completion checklist at the bottom of that doc.
For a fresh release, classify the previous-stable-to-`HEAD` diff as patch or minor and show the target version and rationale to the user. Agents never select a major version autonomously.
The doc covers the changelog, pre-release sanity check, and post-release babysit pattern. Don't skip steps.
Follow the applicable flow and the **Stable release (or promotion)** completion checklist in `docs/release.md` end-to-end.
<!-- 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. -->
- 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))
## 0.3.0 - 2026-08-08
### Added
- New mobile terminal with text selection, copy, paste, and faster rendering ([#1607](https://github.com/getpaseo/paseo/pull/1607), [#2830](https://github.com/getpaseo/paseo/pull/2830))
- Redesigned sidebar with clearer status colours and rows that are quicker to scan ([#2340](https://github.com/getpaseo/paseo/pull/2340) by [@kaspesi](https://github.com/kaspesi), [#2335](https://github.com/getpaseo/paseo/pull/2335), [#2416](https://github.com/getpaseo/paseo/pull/2416) by [@nikuscs](https://github.com/nikuscs), [#2711](https://github.com/getpaseo/paseo/pull/2711))
- Choose what a sidebar workspace row shows: host, pull request, checks, and scripts
- Name each host and give it a colour ([#2790](https://github.com/getpaseo/paseo/pull/2790))
- Search your history by workspace, agent, and branch ([#2995](https://github.com/getpaseo/paseo/pull/2995))
- Use Paseo in Korean ([#2895](https://github.com/getpaseo/paseo/pull/2895) by [@himomohi](https://github.com/himomohi), [@Kesta-bos](https://github.com/Kesta-bos))
- Run git and workspace actions from the Command Center ([#2749](https://github.com/getpaseo/paseo/pull/2749))
- Change model, reasoning, mode, plan, and fast from the Command Center ([#2274](https://github.com/getpaseo/paseo/pull/2274) by [@kedrzu](https://github.com/kedrzu))
- Fork an agent while it's running ([#2638](https://github.com/getpaseo/paseo/pull/2638) by [@kaspesi](https://github.com/kaspesi))
- Launch a terminal directly from New Workspace ([#2941](https://github.com/getpaseo/paseo/pull/2941))
- Jump between prompts in a chat ([#2792](https://github.com/getpaseo/paseo/pull/2792))
- Preview HTML files ([#2712](https://github.com/getpaseo/paseo/pull/2712) by [@nicholas-salgueiro-britecore](https://github.com/nicholas-salgueiro-britecore), [@nickmaglowsch](https://github.com/nickmaglowsch))
- See Claude workflows in the subagent track ([#2933](https://github.com/getpaseo/paseo/pull/2933))
- Choose which orchestration skills Paseo installs ([#2680](https://github.com/getpaseo/paseo/pull/2680))
- Approve all ACP tool calls with one setting ([#2752](https://github.com/getpaseo/paseo/pull/2752))
- Add custom HTTP headers to direct host connections ([#2922](https://github.com/getpaseo/paseo/pull/2922))
- Pick a local branch or its origin counterpart as the base for a new worktree ([#2328](https://github.com/getpaseo/paseo/pull/2328) by [@mcowger](https://github.com/mcowger))
- Paste images from the clipboard on mobile ([#2793](https://github.com/getpaseo/paseo/pull/2793))
### Improved
- Provider data is cached in the client for a faster startup
- Recent chats stay live in the background, so switching back shows current messages right away ([#2842](https://github.com/getpaseo/paseo/pull/2842))
- Coming back to a workspace from Settings is faster ([#2791](https://github.com/getpaseo/paseo/pull/2791))
- Workspace Git status stays responsive in large repositories ([#2979](https://github.com/getpaseo/paseo/pull/2979))
- Paseo runs fewer git processes on busy machines ([#2797](https://github.com/getpaseo/paseo/pull/2797))
- Copying part of an assistant response keeps its lists, links, and formatting ([#2808](https://github.com/getpaseo/paseo/pull/2808), [#2930](https://github.com/getpaseo/paseo/pull/2930), [#2935](https://github.com/getpaseo/paseo/pull/2935) by [@cleiter](https://github.com/cleiter))
- See the full conversation from a native Claude subagent ([#2498](https://github.com/getpaseo/paseo/pull/2498) by [@ebg1223](https://github.com/ebg1223), [#2760](https://github.com/getpaseo/paseo/pull/2760))
- OpenCode subagents show their task, type, model, and token usage ([#2909](https://github.com/getpaseo/paseo/pull/2909) by [@BrianAguilarWasco](https://github.com/BrianAguilarWasco))
- Pi delegated tasks show their lifecycle status ([#2891](https://github.com/getpaseo/paseo/pull/2891))
- Claude remembers model and thinking choices for new workspaces ([#2912](https://github.com/getpaseo/paseo/pull/2912))
- OMP context usage updates while a turn is running ([#2503](https://github.com/getpaseo/paseo/pull/2503) by [@theslava](https://github.com/theslava))
- OpenCode reports background activity from its own busy status ([#2696](https://github.com/getpaseo/paseo/pull/2696) by [@desflynn](https://github.com/desflynn))
- Desktop browser tabs keep their state through focus changes and automation ([#2907](https://github.com/getpaseo/paseo/pull/2907))
- New worktrees start from the tracked upstream branch ([#2848](https://github.com/getpaseo/paseo/pull/2848))
- Generated workspace titles describe the task ([#2755](https://github.com/getpaseo/paseo/pull/2755))
- Relay access is opt-in when you pair a device ([#2706](https://github.com/getpaseo/paseo/pull/2706))
- Voice mode releases the audio session when recording and playback stop ([#2866](https://github.com/getpaseo/paseo/pull/2866) by [@kaspesi](https://github.com/kaspesi))
- Workspace suggestions skip Git-ignored data ([#2902](https://github.com/getpaseo/paseo/pull/2902))
- Reconnect messages distinguish daemon restarts from network interruptions ([#2931](https://github.com/getpaseo/paseo/pull/2931))
- The file viewer shows one accurate status when a file changes or is deleted ([#2670](https://github.com/getpaseo/paseo/pull/2670), [#2694](https://github.com/getpaseo/paseo/pull/2694))
- The Nix desktop package is smaller ([#2550](https://github.com/getpaseo/paseo/pull/2550) by [@colonelpanic8](https://github.com/colonelpanic8))
- The Nix desktop app uses the right icon on Linux and macOS ([#2506](https://github.com/getpaseo/paseo/pull/2506), [#2783](https://github.com/getpaseo/paseo/pull/2783) by [@colonelpanic8](https://github.com/colonelpanic8))
- Paseo Desktop builds from the Nix flake on macOS ([#2556](https://github.com/getpaseo/paseo/pull/2556) by [@colonelpanic8](https://github.com/colonelpanic8))
### Fixed
- Pi 0.84 agents no longer crash-loop on every prompt ([#2978](https://github.com/getpaseo/paseo/pull/2978) by [@dundunHa](https://github.com/dundunHa))
- Workspace file watching no longer stalls the daemon ([#2858](https://github.com/getpaseo/paseo/pull/2858))
- Messages no longer duplicate or arrive out of order after a reconnect or resume ([#2789](https://github.com/getpaseo/paseo/pull/2789), [#2718](https://github.com/getpaseo/paseo/pull/2718))
- Git status and diffs pick up changes in nested folders on every desktop OS ([#2775](https://github.com/getpaseo/paseo/pull/2775))
- Claude runtime failures report an error instead of leaving the workspace idle ([#2910](https://github.com/getpaseo/paseo/pull/2910) by [@nickmaglowsch](https://github.com/nickmaglowsch))
- Claude replay no longer leaves stale running subagents ([#2876](https://github.com/getpaseo/paseo/pull/2876) by [@cleiter](https://github.com/cleiter))
- The Fast toggle appears for Claude Opus 5 ([#2939](https://github.com/getpaseo/paseo/pull/2939) by [@cleiter](https://github.com/cleiter))
- Agents stop with an error when their provider process exits ([#2757](https://github.com/getpaseo/paseo/pull/2757))
- Cancelling an OpenCode turn no longer breaks the next one ([#2662](https://github.com/getpaseo/paseo/pull/2662))
- ACP permission prompts no longer disappear mid-turn ([#2762](https://github.com/getpaseo/paseo/pull/2762))
- A workspace no longer looks idle while its native subagents run ([#2777](https://github.com/getpaseo/paseo/pull/2777))
- Terminal activity stops after an interrupted turn ([#2942](https://github.com/getpaseo/paseo/pull/2942))
- Windows terminals handle deferred startup failures without crashing
- Recreated workspace folders no longer remain incorrectly archived ([#2987](https://github.com/getpaseo/paseo/pull/2987))
- Restoring a merged workspace no longer leaves it inactive ([#2714](https://github.com/getpaseo/paseo/pull/2714))
- Switching workspaces no longer scrolls a chat away from where you were reading ([#2838](https://github.com/getpaseo/paseo/pull/2838))
- Images stay anchored while a conversation reloads
- Swipe and drag gestures work in the mobile sidebar ([#2709](https://github.com/getpaseo/paseo/pull/2709))
- Dictated prompts no longer disappear when you submit them ([#2745](https://github.com/getpaseo/paseo/pull/2745))
- The composer stays above the keyboard on Android
- Typing with an IME on mobile no longer loses composed text
- The composer toolbar no longer flickers when switching tabs ([#2937](https://github.com/getpaseo/paseo/pull/2937))
- Your selected project stays selected when you switch hosts ([#2700](https://github.com/getpaseo/paseo/pull/2700))
- Host connection choices stay selected through Settings and desktop refreshes ([#2905](https://github.com/getpaseo/paseo/pull/2905))
- The pull request panel updates after you switch branches ([#2699](https://github.com/getpaseo/paseo/pull/2699))
- New Workspace shows its isolation controls right away ([#2702](https://github.com/getpaseo/paseo/pull/2702))
- Agents started by the NixOS service no longer run in production mode ([#2697](https://github.com/getpaseo/paseo/pull/2697) by [@shin-sakata](https://github.com/shin-sakata))
| [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/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/side-panel.md](docs/side-panel.md) | The Side panel surface — its three renderings, tab placement intents, pane lifecycle, and the routing preference |
| [docs/ad-hoc-daemon-testing.md](docs/ad-hoc-daemon-testing.md) | Isolated in-process daemon test harness |
@@ -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
[paseo.sh/download](https://paseo.sh/download) 또는 [GitHub 릴리스 페이지](https://github.com/getpaseo/paseo/releases)에서 다운로드하세요. 앱을 열면 데몬이 자동으로 시작됩니다. 별도로 설치할 것은 없습니다.
휴대폰에서 연결하려면 **설정 → 호스트 → 기기 페어링**을 여세요.
### CLI / 헤드리스 환경
CLI를 설치하고 Paseo를 시작하세요.
```bash
npm install -g @getpaseo/cli
paseo
```
Paseo가 로컬에서 시작된 뒤 기기 페어링을 위한 종단 간 암호화 릴레이를 켤지 묻습니다. 거절하면 TCP, Tailscale 또는 다른 VPN으로 직접 연결할 수 있습니다. 이 방식은 서버나 원격 머신에서 유용합니다.
자세한 설치와 설정은 아래 문서를 참고하세요.
- [문서](https://paseo.sh/docs)
- [연결 가이드](https://paseo.sh/docs/connectivity)
- [설정 레퍼런스](https://paseo.sh/docs/configuration)
### Docker
Docker에서 Paseo 데몬과 셀프 호스팅 웹 UI를 실행하세요:
```bash
docker run -d --name paseo \
-p 6767:6767 \
-e PASEO_PASSWORD=change-me \
-v "$PWD/paseo-home:/home/paseo"\
-v "$PWD:/workspace"\
ghcr.io/getpaseo/paseo:latest
```
컨테이너가 시작되면 `http://localhost:6767`을 여세요. 사용하는 에이전트 CLI를 기본 이미지에 추가한 뒤, 환경 변수나 영구 `/home/paseo` 볼륨으로 인증 정보를 설정하세요. 자세한 내용은 [Docker 문서](docs/docker.md)를 참고하세요.
## CLI
앱에서 할 수 있는 모든 작업은 터미널에서도 할 수 있습니다.
```bash
paseo run --provider claude/opus-4.6 "implement user authentication"
paseo run --provider codex/gpt-5.4 --worktree feature-x "implement feature X"
paseo ls # 실행 중인 에이전트 목록
paseo attach abc123 # 실시간 출력 스트리밍
paseo send abc123 "also add tests"# 후속 작업 전송
# 원격 데몬에서 실행
paseo --host workstation.local:6767 run "run the full test suite"
```
자세한 내용은 [전체 CLI 레퍼런스](https://paseo.sh/docs/cli)를 참고하세요.
## 스킬
스킬은 에이전트가 Paseo를 통해 다른 에이전트를 오케스트레이션하는 방법을 알려 줍니다.
```bash
npx skills add getpaseo/paseo
```
그런 다음 어떤 에이전트 대화에서든 아래 명령을 사용할 수 있습니다.
-`/paseo-handoff` — 에이전트 간에 작업을 넘깁니다. Claude로 계획을 세운 뒤 Codex에 구현을 넘길 때 이 기능을 씁니다.
-`/paseo-advisor` — 작업 자체를 넘기지 않고, 에이전트 하나를 조언자로 띄워 두 번째 의견을 받습니다.
-`/paseo-committee` — 서로 다른 관점의 에이전트 두 개로 위원회를 구성해, 한 발 물러나 근본 원인을 분석하고 계획을 세웁니다.
## 개발
모노레포 패키지 구성은 다음과 같습니다.
-`packages/server`: Paseo 데몬(에이전트 프로세스 오케스트레이션, WebSocket API, MCP 서버 제공)
-`packages/app`: Expo 클라이언트(iOS, Android, 웹)
-`packages/cli`: `paseo` CLI(데몬과 에이전트 워크플로)
-`packages/desktop`: Electron 데스크톱 앱
-`packages/relay`: 데몬과 클라이언트가 쓰는 릴레이 전송 및 암호화 패키지
-`packages/website`: 마케팅 사이트 및 문서(`paseo.sh`)
자주 쓰는 명령:
```bash
# 모든 로컬 개발 서비스 실행
npm run dev
# 개별 환경 실행
npm run dev:server
npm run dev:app
npm run dev:desktop
npm run dev:website
# 서버 스택 빌드
npm run build:server
# 레포 전체 검사 실행
npm run typecheck
```
## 관련 프로젝트
- [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — Elixir로 작성한 공식 분산형 릴레이
- [paseo-skins](https://github.com/huangguang1999/paseo-skins) — 커뮤니티 테마와 Agent Skill을 제공하고, 코드 수정 없이 쓸 수 있는 데스크톱 테마 로더
- [paseo-vscode](https://marketplace.visualstudio.com/items?itemName=hinnes.paseo-vscode) — VS Code 확장 프로그램
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.
@@ -126,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.
Idle agents remain resident indefinitely. Runtime closure happens only through an explicit lifecycle
action such as archive, replacement, reload, workspace teardown, or daemon shutdown.
A provider runtime can still die on its own — crash, OOM kill, host suspend. Work the agent parked
inside that process dies with it: Claude Code's background Bash shells, `Monitor` watches, and
workflows all live in the CLI process, and the completion notification that would have woken the
agent never arrives. A runtime that dies mid-turn is reported by whatever is draining its stream, but
between turns nothing is watching, so the agent sits at `idle` looking healthy while its background
work is gone. Report that exit as a turn failure so the agent lands in `error` with a timeline entry.
Only the Claude provider does this today; the others still report a death only when a turn happens to
be in flight.
### Cancellation
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
@@ -35,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
@@ -62,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.
@@ -79,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,
@@ -97,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.
@@ -111,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:
@@ -131,9 +168,10 @@ Provider descriptors may include one compact subtitle. The provider owns its con
Claude Code announces subagent lifecycle on the SDK stream (`task_started` / `task_updated` / `task_notification` / `task_progress`), and Paseo reads those announcements rather than reconstructing them from sidechain frames. The live source (`subagents/live-source.ts`) and the replay source (`subagents/replay-source.ts`) both translate into one observation vocabulary (`subagents/observation.ts`), so a fact is derived once for both paths instead of once per path. Gotchas that are not obvious from the SDK types:
- **Not every announced task is a subagent.** A backgrounded shell announces as `task_type: "local_bash"` with a`tool_use_id`and no `subagent_type`; workflows announce as `local_workflow`; ambient housekeeping sets `skip_transcript`. Filtering on the presence of a `tool_use_id` alone puts `sleep 20` in the subagents track.
- **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.
@@ -141,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
@@ -161,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.
## 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:
```text
major * 1_000_000 + minor * 1_000 + patch
@@ -67,15 +69,26 @@ npm run android:production # Release build
npm run android:clear # Remove generated Android project
```
For a production-ID release APK that local Android profiling tools can attach to:
```bash
PASEO_PROFILE_BUILD=1 npm run android:production
```
This keeps the `sh.paseo` package id, release Hermes bundle, and release optimizations. It adds
`<profileable android:shell="true" />` and enables local Android trace markers for workspace mounts
and daemon WebSocket traffic. The markers contain message types and sizes, never payload contents,
and emit only while a system trace records the `sh.paseo` app (`perfetto -a sh.paseo ...`).
@@ -134,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,46 @@ 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.
-`runtime/replica-cache` keeps the complete project, workspace, and active-agent directory plus one short focused timeline tail in a row store. It restores before navigation becomes ready and leaves remote hydration flags false.
-`runtime/directory-sync` owns directory reconciliation. On reconnect it 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.
The replica cache paints stale data immediately while the host connects. Directory cursors are
reconciliation checkpoints; cached entities remain non-authoritative until the daemon answers.
Pending permission requests are not restored from it. The row store 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. Agents, workspaces, and projects have individual rows; the
focused timeline tail and directory checkpoint each have one row per host. Writes serialize only
rows whose in-memory identities changed. Restore validates every row before hydration; one invalid
row clears the cache and starts cold. The 32 MiB byte budget is tracked incrementally from row
payload lengths and evicts whole hosts in least-recently-written order. After restart, that order is
an approximation rebuilt from the row store's `readAll()` order. Browser and Electron builds use
IndexedDB. Native builds use expo-sqlite.
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 +141,12 @@ Commander.js CLI with Docker-style commands. Common agent operations are also ex
- hidden legacy `paseo worktree create/ls/archive` compatibility alias
@@ -162,6 +183,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 +330,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 +405,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
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
@@ -696,7 +712,7 @@ Each entry in the `models` array:
The built-in `claude` provider appends concrete model IDs from `~/.claude/settings.json` to its first-party Claude model list. Paseo reads the top-level `model` field and these `env` keys: `ANTHROPIC_MODEL`, `ANTHROPIC_SMALL_FAST_MODEL`, `ANTHROPIC_DEFAULT_OPUS_MODEL`, `ANTHROPIC_DEFAULT_SONNET_MODEL`, and `ANTHROPIC_DEFAULT_HAIKU_MODEL`.
This lets users who already configured Claude Code for Bedrock, OpenRouter, ollama, Z.AI, or another Anthropic-compatible gateway select the exact model ID in Paseo. When `agents.providers.claude.models` is set it **replaces** both the hardcoded first-party Claude list and any settings.json-discovered entries; use `agents.providers.claude.additionalModels` to keep the first-party list and append curated entries on top.
This lets users who already configured Claude Code for Bedrock, OpenRouter, ollama, Z.AI, or another Anthropic-compatible gateway select the exact model ID in Paseo. Explicit model IDs are passed unchanged to Claude Code, even when the same string is a compatibility alias for a built-in model. When `agents.providers.claude.models` is set it **replaces** both the hardcoded first-party Claude list and any settings.json-discovered entries; use `agents.providers.claude.additionalModels` to keep the first-party list and append curated entries on top.
### Gotcha: `extends: "claude"` with third-party endpoints
@@ -89,7 +87,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.
@@ -341,7 +372,7 @@ One file per schedule. ID is 8 hex characters.
### Nested: ScheduleTarget (discriminated union on `type`)
Single file containing an array of all loop records. Writes are direct (not atomic) and serialized through an in-memory queue. On daemon startup any record with `status: "running"` is recovered as `"stopped"` with an interruption log entry.
| `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.
@@ -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 15px on every platform. 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`).
@@ -64,7 +66,7 @@ The button is `<Button>` (`packages/app/src/components/ui/button.tsx`). It has f
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`.
@@ -221,13 +223,17 @@ Selected state on rows in a desktop list+detail uses `surfaceSidebarHover` as th
## 13. Status pills and badges
Status pills are `palette.<color>[300]` foreground on a 10%-alpha background of the same color. Success uses green, warning uses amber, danger uses red, muted uses zinc. The `<StatusBadge>` primitive (`packages/app/src/components/ui/status-badge.tsx`) is canonical.
There is exactly one token per status signal — `statusSuccess`, `statusDanger`, `statusWarning`, `statusMerged` — and every status surface uses it: PR state icons, CI check icons and pies, diff stats, file-change icons, status pills, usage bars. A surface does not get a quieter or louder variant because of where it sits. If a dense list feels loud, that is a density or weight problem; fix the density, not the color. The tokens are generated, not hand-picked — see the rule in `packages/app/src/styles/theme.ts` and regenerate rather than nudging one value. The level is set by the densest consumer, the sidebar workspace list.
Status dots — the small filled circles next to a host or agent name — are `borderRadius.full` filled with the status color (`statusSuccess`, `statusWarning`, `statusDanger`, or `foregroundMuted`). They sit in the trailing slot of a sidebar row or as a leading marker on a status pill.
Status **dots** are the one exception, and they are a family of their own — `statusDotSuccess`, `statusDotDanger`, `statusDotWarning`, `statusDotRunning`, read only by `getStatusDotColor` (`packages/app/src/utils/status-dot-color.ts`). Same hues and the same generation rule, but their own band: 90% of gamut chroma against the status family's 55–60%. A dot is a few points of solid color with no shape to read and no label attached, and the running one pulses, so at the status band's chroma the dots read dimmer than the metadata beside them — backwards, since the dot is the row's state. Lightness is set by hue separation rather than by distance from the surface: at 6pt four dark hues on a light surface all read as one dark blob no matter how much contrast they have. So the light band runs as bright as the contrast floor allows at L=0.62, the last step where all four clear 3:1 against the sidebar's `surface2`; the dark band sits at L=0.72, where danger turns pink above. All four move together; regenerate the set, never one hue.
Identity badges — the project icon and the sidebar host badge — 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. Identity color is a fill-only contract: project icons use it as a fill with a white letter, while host badges use it on their icon and retain the normal muted text color. The table is theme-independent by design; do not fork it per theme, and do not add hexes to it without recomputing the band.
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`.
The bespoke pills in `packages/app/src/screens/settings/host-page.tsx:97-116`, `packages/app/src/components/agent-list.tsx:607-632`, and `packages/app/src/components/sidebar-workspace-list.tsx:2889-2894` are drift to be removed. New code uses `<StatusBadge>`.
Status dots — the small filled circles next to a host or agent name — are `borderRadius.full` filled with the status token. Which token a given agent state maps to is owned by `getStatusDotColor` (`packages/app/src/utils/status-dot-color.ts`); a row, a group header, and a project icon all call it rather than restating the mapping. They sit in the trailing slot of a sidebar row or as a leading marker on a status pill.
Identity badges — the project icon, the sidebar host badge, and the PR-panel participant avatar — do not use the theme palette. They draw from the fixed ten-color identity table in `packages/app/src/styles/identity-colors.ts`, whose hexes are held to one contrast band so a color identifies rather than ranks. Project icons and PR avatars use it as a fill with a white letter — that is `identityColor`, one theme-independent hex per identity. Host badges use it as a _foreground_ on both the glyph and the label, which is a different contrast problem that the fill table cannot solve: no single hex clears 4.5:1 against both a near-white and a dark sidebar. Foregrounds therefore come from `identityForeground(name, colorScheme)`, one set per scheme, hue unchanged. That set is generated on the **status family's** lightness and chroma fraction, because a meta row puts a host badge beside a CI check and a diff stat, and two families at different lightness make the brighter one shout. Change the status band and this one changes with it. A host with no color assigned falls back to `foregroundMuted`. The table is theme-independent by design; do not fork it per theme, and do not add hexes to it without recomputing the band.
New status pills use `<StatusBadge>`. Identity, shortcut, and interactive link badges remain separate because color does not encode status there.
---
@@ -238,7 +244,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
The file explorer uses colored SVG icons from [`material-icon-theme`](https://github.com/material-extensions/vscode-material-icon-theme) (installed as a dev dependency in `packages/app`).
Use `packages/server/src/server/file-observer` for recursive filesystem observation. Create one observer service at the owning service boundary, subscribe roots through it, and close it during owner shutdown. Keep platform watchers, directory discovery, exclusion reconciliation, event batching, failure cleanup, diagnostics, and teardown inside the module. A consumer supplies a root and excluded subtrees; it must not branch on the platform or manage child watchers.
Do not replace the Linux implementation with `fs.watch({ recursive: true })`. Node 22 implements Linux recursion in JavaScript by walking the tree and watching every file and directory. Paseo's directory-only Linux watcher already exhausted resources before ignored-tree pruning and a watcher cap were added in [#794](https://github.com/getpaseo/paseo/pull/794).
Closing an observation must remain safe after its root is renamed or removed. Workspace archive removes owned worktrees while releasing observation references, so teardown cannot assume the watched path still exists.
`unsubscribe()` is an awaited barrier. Once it resolves, no scan, reconciliation, queued event, native handle, or callback may remain. `updateIgnore()` is the equivalent barrier for excluded paths: events queued below a newly excluded root cannot be delivered after it resolves. Treat path disappearance while scanning, attaching, or receiving a watcher error as rename/archive churn. Surface permission and resource errors so WorkspaceGit can enter bounded polling.
Linux topology repair is trailing-debounced and scoped to the renamed path. A file rename needs one classification stat; a new or moved directory scans only its subtree. Full-tree scans are reserved for startup and ignore-set replacement. The initial Linux scan conservatively emits existing files discovered before their directory watcher was installed; WorkspaceGit batches these into one refresh. Benchmark this startup cost with pre-populated trees.
Windows and macOS use one native recursive watcher plus an ignored-pruned, directory-indexed inventory. Native events deliver immediately. Named events trigger serialized shallow scans of their parent directories; known directory events also reconcile that subtree. This recovers concrete create and delete paths omitted by coalescing without walking the whole root for each edit. 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.
@@ -10,7 +10,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
- **Agent** — See **Agent session**. UI still says "Agent" / "New Agent" in places, but moving toward **Agent session** as the canonical term. Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`). Forbidden: "Task", "Job", "Run".
- **Daemon** — Local Paseo server process; identified by `serverId`. UI: "Daemon" (system contexts only). Code: `serverId` in `ServerInfoStatusPayloadSchema` (`packages/protocol/src/messages.ts:1936`), `DaemonClient` (`packages/client/src/daemon-client.ts`).
- **Host** — Client-side connection profile pointing at a daemon; bundles one or more `HostConnection`s. UI: "Host" / "Add host" / "Switch host". Code: `HostProfile` (`packages/app/src/types/host-connection.ts:37`). Forbidden: "Connection" (means `HostConnection`, not host).
- **Host badge** — The sidebar pill naming which host a workspace lives on. Only drawn once the visible sidebar spans more than one host. Its name, color, and display mode (**Name** / **Icon only** / **Hidden**) are per-host, per-device, and live client-side on `HostProfile.appearance`; nothing about it reaches the daemon. UI: the "Appearance" group on a host's settings page. Code: `HostBadge` (`packages/app/src/components/sidebar/host-badge.tsx`), `selectHostBadges` (`packages/app/src/hosts/appearance.ts`). Don't confuse with **Status pill**, which reports state rather than identity.
- **Host badge** — The item on a sidebar workspace row's meta row naming which host that workspace lives on. Three things gate it, and they compose as a lattice rather than competing: the sidebar must span more than one host (automatic), **Show → Host** in the sidebar's display preferences must be on (global, all hosts at once), and then that host's own display mode decides _how_ it draws — **Name** / **Icon only** / **Hidden**. Name, color, and display mode are per-host, per-device, and live client-side on `HostProfile.appearance`; nothing about it reaches the daemon. Never add a fourth way to hide it. UI: the "Appearance" group on a host's settings page, and the "Show" submenu in the sidebar's display preferences. Code: `selectHostBadges` (`packages/app/src/hosts/appearance.ts`) takes the first two as its `enabled` input; `WorkspaceMetaRow` (`packages/app/src/components/sidebar/workspace-meta-row/index.tsx`) draws it. Don't confuse with **Status pill**, which reports state rather than identity.
- **Project host entry** — One row in a project for a single (project, daemon) pair, aggregating that daemon's workspaces in the project. Internal. Code: `ProjectHostEntry` (`packages/app/src/utils/projects.ts:11`). Don't introduce "Checkout" as a synonym.
- **Placement** — One workspace's stable foreign-key relationship to its project plus its git checkout snapshot. Internal. An explicit creation `projectId` is authoritative when active.
@@ -19,17 +19,22 @@ 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.
- **Side panel** — The companion surface beside the user's work, holding Changes, Files, the pull request, and tabs an agent opens alongside its output. UI: "Side panel". Code: `packages/app/src/workspace-tabs/side-panel.ts`; the persisted pane id and layout key keep the pre-rename `explorer` spelling. See [side-panel.md](side-panel.md). Forbidden: "Explorer" as the name of the surface — **File Explorer** is only the Files view inside it.
- **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`).
- **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).
- **Workspace script** — A named command a workspace can run, declared in `paseo.json`. Two types: `service` and `script` (a command that runs and finishes). UI: "Scripts" for the panel and the menu, both of which list either type. Code: `WorkspaceScriptPayload` (`packages/protocol/src/messages.ts:3126`). Don't confuse with **Service**, which is one of the two types rather than a synonym for the pair.
- **Service** — A workspace script of type `service`: long-running, may bind a port, and carries `health` of `healthy | unhealthy | null` once the daemon has observed it. The only type a sidebar workspace row reports, because a running plain script says nothing about whether the workspace is up; the row names the service and turns danger when its health fails, and where several run the failing one takes the row's single slot. UI: "Services" in the sidebar's display preferences (**Show → Services**). Code: `selectWorkspaceServiceSummary` (`packages/app/src/components/sidebar/workspace-meta-row/service-summary.ts`), `WorkspaceScriptHealthSchema` (`packages/protocol/src/messages.ts:3124`). Forbidden: "Service" as a label for the Scripts panel, which lists both types.
- **Schedule** — Cron-style trigger that creates new agents. UI: CLI/MCP (`paseo schedule`, `create_schedule`). Don't confuse with: Heartbeat (cron prompt back into the same agent).
- **Heartbeat** — Ephemeral cron prompt sent back into the same agent/conversation. Agent surfaces expose create, update cron, and delete only. Use for reminders and babysitting where status should return inline.
- **Attachment** — 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`).
@@ -38,7 +43,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).
@@ -96,6 +96,21 @@ Any gap between A and B (margins between siblings inside the same parent) is par
If A and B genuinely can't share a parent — B portals into a different layer, floats above other content — see [Section: real gaps](#real-gaps-with-floating-panels) below.
### Failure mode 4 — A hover-revealed trigger unmounts its own open menu
A kebab that only exists while its row is hovered opens a menu, and that menu takes the pointer
off the row: a sheet slides up over it, a popover covers it. The row un-hovers, the trailing
overlay unmounts, and the trigger goes with it — taking the menu's open state, which lived
inside the trigger's subtree. The surface itself is already presented in a portal, so nothing
unmounts it and nothing is left that can close it. On a bottom sheet that means a full-screen
backdrop swallowing every click until reload.
Lift the menu's open state to whatever decides to render the trigger, and keep the trigger
rendered while the menu is up. `useOpenKebabMenuVisibility`
(`packages/app/src/components/sidebar/use-open-kebab-menu-visibility.ts`) is that shape for the
sidebar rows: it owns `open`, hands the menu its controlled props, and ORs `open` into the
row's own `showKebab`.
## Native fallback
Hover doesn't exist on touch devices. Anything you hide behind hover must have a non-hover path on native and compact layouts:
@@ -134,5 +149,6 @@ Before opening a PR that touches hover:
- [ ] Hovered state does **not** change the trigger's outer geometry (`width`, `height`, `padding`, `borderWidth`, mount/unmount of siblings that shift it). Internal swaps fit inside a fixed `minHeight` / `minWidth`.
- [ ] Revealed content inside the trigger uses `opacity` + `pointerEvents`, not conditional rendering, if mounting it would reflow the trigger.
- [ ] Visibility on native and compact layouts works without hover (`isHovered || isNative || isCompact`).
- [ ] A menu opened from the revealed trigger keeps the trigger rendered while it is open, so losing hover can't strand it.
- [ ] If the revealed content sits in a separate layer (portal, floating panel), `useHoverSafeZone` is wired up.
- [ ] You opened the dev server, hovered the trigger, and slowly moved the mouse along **every** revealed element — including any visible gaps — without losing hover state.
Paseo Hub is an explicit opt-in connection from one Paseo daemon to one Hub. Running a daemon does
not register it with a Hub. The relationship begins only when a user runs
`paseo hub connect <url> --token <token>` from the daemon machine.
`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.
## Connection and authority
@@ -44,12 +46,30 @@ daemon, execution, and agent identity with that terminal state. Paseo never stor
replays the original prompt. A duplicate create returns the existing agent without starting another
turn.
Hub creates use the same agent creation path as trusted clients. They may select any existing
worktree target shape and carry optional MCP server configuration for the agent session. The daemon
keeps that configuration in its private agent record so provider sessions can recover after a
restart; neither ordinary client snapshots and updates nor Hub projections expose session
configuration. Execution completion policy remains outside the daemon: a completed agent turn does
not imply that the Hub execution is terminal.
Every Hub execution creates a fresh Paseo workspace. The workspace owns the execution's agents and
terminals. Local checkout and worktree targets select only the workspace backing and isolation; the
Hub cannot select or reuse an existing workspace. Hub creates use the same agent creation path as
trusted clients. They may select any worktree target shape and carry optional MCP server configuration and provider-native
`providerOptions` for the agent session. The daemon keeps that configuration in its private agent
record so provider sessions can recover after a restart; neither ordinary client snapshots and
updates nor Hub projections expose session configuration. See [providers.md](providers.md) for the
supported provider keys.
Hub tool preapproval is a private, structured list of `{ kind: "mcp", server, tool }` references.
Every reference must name an MCP server injected by the same create request. The daemon translates
only those identities into the selected provider's native approval configuration. The protocol
cannot name or preapprove native tools such as Bash, Edit, or Write. Explicit local or managed ask
and deny policy takes precedence. Providers without exact MCP preapproval support reject unattended
Hub creation instead of broadening access or waiting for an invisible prompt.
When a create request includes a tool policy, a successful response includes
`toolPolicyApplied: true`; absence of that acknowledgement is not success for unattended execution.
A Hub workflow may use read-only provider settings for classifier steps. This is defense in depth,
not a security boundary: classifier labels and prompt intent do not authorize tools. Exact MCP grants
and the provider's local or managed policy remain the authorization controls.
Execution completion policy remains outside the daemon: a completed agent turn does not imply that
the Hub execution is terminal.
The Hub ends an execution by sending `hub.execution.control.request` with the durable execution ID
and either `interrupt` or `archive`. The daemon resolves the agent from the authenticated daemon
@@ -59,11 +79,11 @@ If no execution exists for that authenticated daemon and execution ID, interrupt
success because the requested stopped or archived state already holds. An execution owned by another
daemon is indistinguishable from a missing execution and is never exposed or affected.
Interrupt uses the ordinary agent cancellation lifecycle. Archive first archives the owned agent.
When that agent belongs to an active Paseo-owned worktree workspace, the daemon also archives the
workspace through the shared workspace archive service, so the backing directory is removed only
after its final active workspace reference disappears. Local and shared checkouts archive only the
execution-owned agent.
Interrupt uses the ordinary agent cancellation lifecycle. Archive resolves the execution agent's
required workspaceId and sends it through the shared workspace archive service. The service archives
that workspace's agents and terminals, then removes Paseo-owned backing directories only after their
final active workspace reference disappears. Local checkouts remain on disk; sibling workspaces
sharing a backing directory remain active.
## Disconnect and revocation
@@ -75,11 +95,13 @@ 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.
## Cross-repository compatibility
@@ -87,3 +109,5 @@ The consumer implementation lives in Paseo Cloud. Cloud owns its copy of the Hub
has no Paseo runtime or build dependency. Cross-repository end-to-end verification separately builds
a Paseo source checkout and exercises the real daemon, CLI, direct WebSocket, Cloud service, and
Postgres. That compatibility fixture is not a package dependency or fallback implementation.
Its `hub-e2e` ACP provider accepts only exact tool names on the injected `hub` MCP server. Other
custom ACP providers remain unsupported for unattended preapproval.
@@ -9,11 +9,12 @@ Paseo client UI translations live in `packages/app/src/i18n`.
- `es`
- `fr`
- `ja`
- `ko`
- `pt-BR`
- `ru`
- `zh-CN`
The persisted app language setting is `"system" | "ar" | "en" | "es" | "fr" | "ja" | "pt-BR" | "ru" | "zh-CN"`. `"system"` follows the device or browser locale when it maps to a supported locale; unsupported system locales fall back to English. Japanese maps from system locales `ja` and Japanese regional locales. Brazilian Portuguese maps from system locales `pt-BR` and bare `pt`; other Portuguese regional locales remain unsupported until explicitly added.
The persisted app language setting is `"system" | "ar" | "en" | "es" | "fr" | "ja" | "ko" | "pt-BR" | "ru" | "zh-CN"`. `"system"` follows the device or browser locale when it maps to a supported locale; unsupported system locales fall back to English. Japanese maps from system locales `ja` and Japanese regional locales. Korean maps from system locales `ko` and Korean regional locales. Brazilian Portuguese maps from system locales `pt-BR` and bare `pt`; other Portuguese regional locales remain unsupported until explicitly added.
## Translation Scope
@@ -23,7 +24,7 @@ Do not translate agent output, daemon output, terminal contents, file paths, pro
## Adding Copy
English source strings live in `packages/app/src/i18n/resources/en.ts`. Simplified Chinese strings live in `packages/app/src/i18n/resources/zh-CN.ts`.
English source strings live in `packages/app/src/i18n/resources/en.ts`. Each supported locale has a matching resource in `packages/app/src/i18n/resources`.
For migrated screens and components, use `useTranslation()` and pass translated text into UI primitives. Low-level primitives such as `<Button>` do not import translation state unless they own the text they render.
@@ -89,3 +90,4 @@ Within a migrated surface, do not leave mixed-language neighboring labels when t
- Batch 4Y tightened the translation boundary so React components and custom hooks use `useTranslation()` while pure helpers keep direct `i18n.t(...)` fallbacks, and migrated remaining small UI/accessibility fallbacks across message details, menu backdrops, startup errors, sidebar PR badges, settings/project accessibility labels, composer send/create/download fallbacks, client slash-command descriptions, terminal subscribe errors, and desktop update completion text. Provider catalog metadata, shortcut registry fallbacks, agent/daemon/protocol reasons, terminal contents, raw runtime errors, and user/project/workspace names remain untranslated.
- Batch 4Z expanded the supported locale set to the six UN official languages: Arabic, Chinese, English, French, Russian, and Spanish. Arabic, French, Russian, and Spanish now have full client-owned UI resource coverage, with key parity, fallback-ratio, and interpolation-placeholder tests guarding the generated translations. Arabic does not enable RTL layout direction in this batch.
- Batch 5A added Brazilian Portuguese (`pt-BR`) resource coverage, language selector labels, i18next registration, and system-locale mapping for `pt-BR` and bare `pt`. Non-Brazilian Portuguese regional locales remain unsupported until a matching resource is added.
- Batch 5B added Korean (`ko`) resource coverage, language selector labels, i18next registration, and system-locale mapping for `ko` and Korean regional locales.
Expected result: Paseo does not throw `RuntimeException: Audio focus request failed`; native audio reports an interruption and voice mode stops or pauses coherently.
### Releasing the audio session when idle
Paseo must not hold the OS audio session once it is neither capturing nor playing, or the user's
background music stays paused. On iOS this is not just a "while recording" problem: the
`.playAndRecord`/`.voiceChat` category is non-mixing and survives backgrounding, and iOS re-asserts
it every time the app returns to the foreground — so one dictation turn kills music for the life of
the process, on every open, until the app is force-quit. Android holds
`AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE` with the same effect.
`releaseAudioSession()` whenever capture stops and the playback queue drains, and the iOS module
also releases on `OnAppEntersBackground`. The native side re-guards on `isRecording` /
`speechPlayer.isPlaying`, because the native engine is a singleton shared by multiple JS engine
wrappers (voice provider + dictation) and only it knows the true state.
This cannot be validated by a JS test — verify on a device: play music, open Paseo, use dictation
once, stop, and confirm the music resumes; then background/foreground the app and confirm it keeps
playing.
## Unistyles + Reanimated
### The crash
@@ -320,6 +339,111 @@ Platform-specific stream edges belong on `StreamStrategy`:
If a chat footer looks duplicated or appears above the assistant message on mobile, start with `packages/app/src/agent-stream/layout.test.ts`. Do not add a React Native renderer test for this class of bug; make the pure layout invariant fail first.
## Local iOS device builds
Native changes (anything under `packages/expo-two-way-audio/ios/`, or any new Expo module) **cannot be
tested over Metro or EAS Update**. Those ship JS only. A stale dev-client binary silently lacks the new
native functions, so the feature no-ops and the test proves nothing. Rebuild the binary.
Symptom of a stale dev client: `Cannot find native module 'ExpoDocumentPicker'` at startup, followed by a
cascade of `Route "./X.tsx" is missing the required default export` warnings. The route warnings are not a
router bug — the module-level throw aborts `_layout.tsx` evaluation, so its default export never gets
assigned. One missing native module, N confusing warnings.
**Prerequisite: Xcode needs a signed-in Apple ID for the signing team _with a valid keychain token_.** An
account that merely appears in Xcode → Settings → Accounts is not enough — if its `Xcode-Token` is missing,
the build below fails at code signing and blames a _certificate_ instead. See
[Signing needs a working Apple ID token in Xcode](#signing-needs-a-working-apple-id-token-in-xcode) before
`repo_clone`, `repo_overview`, `lsp`, `doom_loop`, and `skill`. See the
[OpenCode permissions reference](https://opencode.ai/docs/permissions/). OpenCode permissions are
application policy, not an OS sandbox.
Each provider definition owns its option schema and exact MCP preapproval mapping. A new provider
must fail closed for Hub unattended execution until it can approve one exact injected MCP server
and tool identity without approving native tools.
## Two Integration Patterns
### ACP (Agent Client Protocol) -- recommended
@@ -46,10 +81,18 @@ OpenCode owns user message IDs. Do not pass Paseo-generated IDs to OpenCode prom
`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. 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()`.
`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.
@@ -68,8 +111,17 @@ 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.
Settings refresh is the user-facing "forget stale provider knowledge everywhere" action. A settings refresh clears provider snapshot caches and in-flight loads across all cwd scopes, then immediately refreshes only the global snapshot with `force: true`. Workspace snapshots are re-probed lazily on the next scoped read; do not fan out a settings refresh across every known workspace.
Registry/config replacement may update visible metadata such as label, description, default mode, enabled state, and provider membership, but it must not spawn provider processes. If a provider needs to be re-probed after a config change, route that through the explicit settings refresh path.
@@ -93,6 +145,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`.
@@ -9,19 +9,17 @@ A release has exactly two steps. The agent does the first, the user authorizes t
**Preparation** (local, reversible — agent does this):
- format, lint, typecheck all green
- ACP provider catalog drift checked with `npm run acp:version-drift:check`;
if stale package-runner pins are intentional, say so explicitly, otherwise run
`npm run acp:version-drift:update` and commit the updated catalog
- classify the previous-stable-to-`HEAD` diff as patch or minor, then show the
- resolve the release source to one commit and confirm that commit's existing CI is green
- classify the diff from the previous stable to the release source as patch or minor, then show the
target version and rationale to the user
- draft the changelog, show it to the user, wait for review
- run the pre-release sanity check, surface findings to the user
- confirm CI is green
**Go-ahead** (user says "go ahead"):
- commit the approved changelog
- run the release
- commit the approved release inputs locally
- run the release, which publishes npm and pushes the prepared branch and tag
- create the release heartbeat immediately and babysit it to completion
Rules that apply to both steps:
@@ -31,12 +29,43 @@ Rules that apply to both steps:
- Invoking a release skill is intent to start the flow, not blanket authorization to publish.
- If the user asks for a release preview, show the prospective changelog/release contents and answer questions, but do not commit, tag, publish, or run release commands until they explicitly authorize the release.
## Release source and CI
The default release source is `origin/main`. Fetch `origin`, then record the
resolved commit. The default release checkout is a clean local `main` whose
`HEAD` equals `origin/main`.
An explicit user instruction can select another ref, such as a hotfix commit or
tag. Resolve that ref once and apply every source, diff, and CI check to that
commit instead of `origin/main`.
Before making release-preparation commits, confirm the existing CI run for the
resolved commit is green. Pending CI is watched to completion. Release
preparation then stays local through the changelog, any explicitly requested ACP
catalog update, lockfile preparation, and the version commit. After approval,
commit the prepared inputs locally and run the release command. Its branch and
tag push is the one remote release batch and starts CI for the complete release
commit.
## ACP catalog updates
ACP catalog work enters a release through an explicit user request:
- **Check ACP drift** — run `npm run acp:version-drift:check`. When drift exists,
run `npm run acp:version-drift:update`, verify the catalog, and include the
update in the local release-preparation commits.
- **Update ACP** — run `npm run acp:version-drift:update`, verify the catalog, and
include the update in the local release-preparation commits.
The release authorization covers the requested ACP commit. It ships in the same
release push as the changelog and version commit.
## Two paths
There are two supported ways to ship from `main`:
There are two supported release paths:
1. **Direct stable release**: you are ready to ship the current `main` commit to everyone immediately.
2. **Beta flow**: release candidates on the `beta` channel. Betas carry an in-place changelog entry (beta users check it), publish npm only on the explicit `beta` dist-tag, and never move the website download target off the latest stable.
2. **Beta flow**: release candidates on the `beta` channel. Each beta carries its own changelog entry, publishes npm only on the explicit `beta` dist-tag, and stays behind the Stable/Beta switch on `/download`.
Paseo has one linear release track even though npm dist-tags are independent
pointers. The npm invariant is:
@@ -48,8 +77,9 @@ pointers. The npm invariant is:
## Release version decision
Every fresh release starts by classifying the full previous-stable-to-`HEAD`
diff. The highest-impact change determines the version:
Every fresh release starts by classifying the full diff from the previous
stable to the resolved release source. The highest-impact change determines the
version:
- **Minor** — a user would experience the release as a significant upgrade. This
includes substantial new workflows, providers, forges, platforms, integrations,
@@ -72,7 +102,7 @@ as described in **Fixing a failed release build**.
Before running any stable release command:
- Make sure the intended release commit is already committed to `main` and the working tree is clean.
- Make sure the resolved release source passed CI, the approved release inputs are committed locally on the intended branch, and the working tree is clean.
- **Run `npm run format`, `npm run lint`, and `npm run typecheck` and commit any resulting changes BEFORE you start any `release:*` command.**`release:check` runs `npm install --workspaces --include-workspace-root` as part of `release:prepare`, which can mutate `package-lock.json` (e.g. churning `"dev": true` markers on optional deps). The next step, `version:all:*`, runs `npm version` which aborts when the working tree is dirty. If this happens mid-flight you have to commit the lockfile churn before retrying — and the pre-commit format hook will reject a lockfile-only commit because oxfmt internally skips `package-lock.json` while lefthook's glob still matches it. Avoid the whole mess by running format/lint/typecheck first, then `release:prepare` once on its own to absorb any lockfile churn into a normal commit, then start the release.
- Do not use a release command as a substitute for checking whether the current commit is actually ready.
@@ -82,7 +112,7 @@ npm run release:patch
npm run release:minor
```
This bumps the version across all workspaces, runs checks, publishes to npm, and pushes the branch + tag. The tag push triggers `Desktop Release`, `Android APK Release`, `Docker`, and `Release Notes Sync` on GitHub Actions. EAS picks up the same tag via the EAS GitHub app and starts the iOS + Android store builds in parallel (see "Mobile builds (EAS)" below) — there is no `release-mobile.yml` in this repo.
This bumps the version across all workspaces, runs checks, publishes to npm, and pushes the branch + tag. The tag push triggers `Desktop Release`, `Android APK Release`, `Docker`, and `Release Notes Sync` on GitHub Actions. EAS picks up the same tag via the EAS GitHub app and starts the iOS + Android store builds in parallel (see "Mobile builds (EAS)" below) — there is no mobile-release workflow under `.github/workflows`.
After the stable release succeeds, move npm's `beta` pointer to the new stable
version for every published package. This changes dist-tags only; do not
@@ -129,11 +159,11 @@ npm run release:promote # Promote X.Y.Z-beta.N to stable X.Y.Z
- Beta tags are published GitHub prereleases like `v0.1.41-beta.1`
- Betas publish npm packages with `--tag beta`, so `npm install @getpaseo/cli@beta` opts in while plain `npm install @getpaseo/cli` stays on `latest`
- Betas publish desktop assets and APKs for testing, but they do not trigger the production web/mobile release flows
- Betas publish desktop assets and APKs for testing. They also build iOS, upload it to TestFlight, add it to the `Paseo Beta` external group, and submit it for Beta App Review. They do not submit mobile builds to the production stores.
- `release:promote` creates a fresh stable tag like `v0.1.41`; the final release never reuses the beta tag
- Desktop assets now come from the Electron package at `packages/desktop`
- Beta releases use Electron's `beta` update channel. Users on the stable channel only receive stable releases; users on the beta channel receive beta releases and the final stable release when it is published.
- **Betas carry a changelog entry.**Beta users read release notes, so each beta updates an in-place `CHANGELOG.md` entry (`## X.Y.Z-beta.N`) that `Release Notes Sync` mirrors into the prerelease body on the tag push. The entry is intermediary: promotion overwrites it in place with the final stable entry, so no `-beta.N` heading is ever left behind. See the Changelog policy section.
- **Each beta carries its own changelog entry.**`Release Notes Sync` mirrors the matching `## X.Y.Z-beta.N` entry into that prerelease body. Promotion collapses every beta entry for the version into one final stable entry. See the Changelog policy section.
Use the beta path when you need to:
@@ -238,9 +268,11 @@ iOS and Android store builds are not in `.github/workflows`. They are triggered
- **iOS (TestFlight + App Store)** — EAS builds with profile `production`, uploads to TestFlight, and a Fastlane lane submits the build for App Store review.
- **Android APK (GitHub Release asset)** — separate, via `.github/workflows/android-apk-release.yml`. This is the only Android-related workflow that lives in this repo.
EAS uses the local app version source. `packages/app/app.config.js` derives Android `versionCode` and iOS `buildNumber` from the package version as `major * 1_000_000 + minor * 1_000 + patch`, ignoring prerelease metadata. Rebuilding the same tag produces the same native build number; if a store has already accepted a binary and you need a different binary, cut a new patch instead of relying on EAS remote auto-increment.
EAS uses the local app version source. `packages/app/app.config.js` derives the native version from the package version. Android `versionCode` is `major * 1_000_000 + minor * 1_000 + patch`. iOS reserves 1,000 build slots per app version: beta `N` uses slot `N`, and stable uses slot `999`. For example, `0.2.6-beta.2` appears in App Store Connect as version `0.2.6` build `2006002`; stable uses build `2006999`. Rebuilding the same tag produces the same native build number; if a store has already accepted a binary and you need a different binary, cut the next beta or patch instead of relying on EAS remote auto-increment.
There is no`release-mobile.yml` in this repo. Earlier versions of these docs referenced one — that workflow was removed and the EAS GitHub app handles tag triggering directly.
Beta tags run`Release iOS Beta`. The workflow uploads the build to TestFlight, distributes it to the persistent `Paseo Beta` external group, and submits it for Beta App Review. Testers and the group are managed once in App Store Connect; releases require no dashboard action.
There is no mobile-release workflow under `.github/workflows`. The EAS GitHub app reads the workflows under `packages/app/.eas/workflows` and handles tag triggering directly.
### Watching mobile builds from the terminal
@@ -289,11 +321,30 @@ Do not treat `build_ios: SUCCESS` or `submit_ios: SUCCESS` as a completed iOS re
To confirm the submission landed, inspect the EAS workflow with `npx eas workflow:view <workflow-run-id> --json`. App Store Connect (review state for the matching version/build) and the Play Console track are the final ground truth.
### Babysitting mobile after a release
## Release completion and heartbeat
The user rarely opens the Expo dashboard. A failed EAS build or submit/review job can sit silently until users complain about a stale version. After every stable release, set up a long-delay babysit that re-checks GitHub Actions, EAS builds, and the EAS `Release Mobile` workflow for the release tag. If any build is `ERRORED`/`CANCELED`, any workflow is `FAILURE`, or any required submit/review job fails, surface it immediately. If all builds are `FINISHED` and all required submit/review jobs are `SUCCESS`, confirm and stop.
A release is **in progress** after npm publication and tag push. Report it as
**shipped** only after every applicable build, publication, asset, manifest, and
store submission passes the completion checklist.
**Use `create_heartbeat`, never `create_schedule`, for release babysitting.** Babysitting fires back into the current conversation as a wake-up prompt. `create_schedule` starts a fresh agent the user has to find and read; `create_heartbeat` surfaces the build status inline in the conversation that owns the release, where it is impossible to miss. If you find yourself reaching for `create_schedule` for a release babysit, you are about to ship a status report into a void.
Immediately after every beta, stable, or promotion tag push, create a heartbeat
that resumes the release in the current conversation. Create it automatically
with `create_heartbeat`. The heartbeat owns the release until it either reaches
the completion checklist or finds a failure that needs new user authority.
Each heartbeat checks the release tag commit, all GitHub Actions runs for the
release branch and tag, npm dist-tags, the GitHub Release body and assets,
desktop updater manifests, the published Docker image, and the applicable EAS
workflow. Inspect the GitHub Release itself and confirm that the macOS, Linux,
Windows, and Android APK assets are present along with the channel manifests
(`latest-mac.yml`, `latest-linux.yml`, and `latest.yml` for stable;
`beta-mac.yml`, `beta-linux.yml`, and `beta.yml` for beta).
For stable releases, also confirm every required mobile build, upload, store
submission, and review-submission job for the release commit. For betas, confirm
the beta EAS workflow completed its TestFlight distribution and Beta App Review
path. Delete the heartbeat only after every applicable checklist item passes,
then report the release as shipped.
Pattern:
@@ -301,13 +352,16 @@ Pattern:
// mcp__paseo__create_heartbeat arguments
{
"name": "vX.Y.Z release babysit heartbeat",
"cron": "*/15****",
"maxRuns": 8, // covers ~2h of build + store-submission window
"prompt": "Heartbeat: check vX.Y.Z release. Run gh run list, eas build:list, eas workflow:runs, and eas workflow:view for the matching Release Mobile run. Report concisely. The release is not done until desktop/APK workflows are green, EAS builds are FINISHED, Android submit_android is SUCCESS, and iOS submit_ios + submit_ios_for_review are SUCCESS. Flag any ERRORED/FAILED/CANCELED/FAILURE loudly.",
"cron": "*/10****",
"timezone": "UTC",
"maxRuns": 120,
"expiresIn": "24h",
"prompt": "Resume the vX.Y.Z release babysit for commit <sha>. Check npm tags; every GitHub Actions run for the release branch and tag; the published GitHub Release body, expected desktop/APK assets, and channel manifests; the Docker image; and the matching EAS workflow. Completion requires every applicable checklist item. For stable, require build_ios, submit_ios, submit_ios_for_review, build_android, and submit_android to succeed. For beta, require the beta TestFlight distribution and Beta App Review path. If work is pending, wait for the next heartbeat. If a failure can be retried safely for the same version, follow the failed-release procedure; otherwise report the blocker. When every applicable completion-checklist item passes, delete THIS heartbeat, report shipped, and stop.",
}
```
Tight cadence on purpose. The first run fires immediately, giving a near-real-time status check before the conversation closes. Subsequent runs at 15-minute intervals catch transitions quickly: a failed EAS build or failed App Store review submission at +20m should not wait until +50m to surface. Keep the prompt short — the heartbeat is a status probe, not a research task — and have it bail out as soon as every platform is actually on its store path so the remaining runs do not generate noise.
Run an immediate status check after creating the heartbeat. The heartbeat handles
later transitions and stops itself when the release is complete.
## Release notes on GitHub
@@ -315,10 +369,12 @@ The GitHub Release body is populated automatically by the `Release Notes Sync` w
## Website behavior
- The website download page points to GitHub's latest published **stable** release.
- Published beta prereleases are public on GitHub Releases, but they do **not** become the website download target.
- The download target only moves when you publish the final stable release tag like `v0.1.41`.
- The public `/changelog` page renders `CHANGELOG.md` as-is, so the in-flight `-beta.N` entry shows there once it lands on `main` — that's intended, it's where beta users check what's coming. Only the **download target** stays pinned to the latest stable; the download links read GitHub's releases API, not the changelog, so a `-beta.N` heading on top never affects them.
- The website download page defaults to GitHub's latest published **stable** release.
- A published beta prerelease is offered behind the Stable/Beta switch on `/download` (`?channel=beta`), never as the default. The switch only appears while the newest prerelease leads stable on its core version, so promoting `X.Y.Z-beta.N` to `X.Y.Z` retires the beta channel from the page until the next beta line opens.
- Homebrew, the Play Store, the App Store, and `app.paseo.sh` have no beta. The Beta view drops those rows, and the whole Web section, rather than showing an inert "stable only" placeholder. When a surface gains a beta path — say a public TestFlight link — add its row back in `packages/website/src/routes/download.tsx`.
- The default download target only moves when you publish the final stable release tag like `v0.1.41`.
- The public `/changelog` page renders `CHANGELOG.md` as-is, so the in-flight `-beta.N` entry shows there once it lands on `main` — that's intended, it's where beta users check what's coming. Only the **default download target** stays pinned to the latest stable; the download links read GitHub's releases API, not the changelog, so a `-beta.N` heading on top never affects them.
- The download page's "What's new" link deep-links the **minor group** anchor (`/changelog#release-0.3`), not the exact entry: promotion collapses the beta entries into one stable entry, so the minor group remains the durable target. A version with no entry in the bundled changelog — a tag whose changelog commit hasn't redeployed the site yet — links the plain `/changelog` instead of a dead anchor.
- The website itself is deployed by `Deploy Website` (Cloudflare Workers), which redeploys on `release: published` for non-prerelease releases and on pushes to `main` that touch `CHANGELOG.md` or `packages/website/**`.
## Fixing a failed release build
@@ -380,6 +436,7 @@ This ensures the checkout ref matches the actual code on `main` with the fix inc
- 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
@@ -399,41 +456,49 @@ No prefix (`v`), no extra text. `Release Notes Sync` matches the `## X.Y.Z` (or
## Changelog policy
- `CHANGELOG.md` includes stable releases and the current beta line.
- `CHANGELOG.md` includes stable releases and every entry in the current beta series.
- The first beta of a version inserts a top entry like `## 0.1.60-beta.1 - YYYY-MM-DD`.
- Each subsequent beta updates that same top entry in place — bump the heading (`0.1.60-beta.1` → `0.1.60-beta.2`) and fold in whatever else landed.
- Stable promotion updates that same entry in place one last time: heading to `0.1.60`, date to the promotion day.
- One entry per version line. The `-beta.N` heading is intermediary — overwrite it, never append. Don't leave stale `-beta.N` entries behind and don't create a duplicate entry per beta.
- It always covers the full diff from the previous stable tag, regardless of how many betas were cut in between.
- Each subsequent beta inserts a new top entry with the next beta number. Its notes cover the changes since the previous beta tag.
- Stable promotion replaces every beta entry for that version with one `## 0.1.60 - YYYY-MM-DD` entry.
- The promoted stable entry covers the full diff from the previous stable tag and collapses internal iterations across the beta series.
## Changelog ownership
- **The agent running the release writes the changelog entry — beta or stable.**Do not hand the changelog to another model or agent. The release agent has the release context and owns the final wording.
- Draft the entry from the previous-stable-to-`HEAD` diff, review it against the changelog policy below, show it to the user, and wait for approval before committing it. Each beta refreshes the same entry; promotion refreshes it one last time from the full previous-stable-to-`HEAD` diff.
- **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.
- **Collapse internal iterations.** If a feature was added and then fixed within the same release, just list the feature as working. Users never saw the broken version.
- **Only list changes relative to the previous stable release.** The diff is `v(previous)..HEAD`. If something was introduced and fixed between those two tags, it never shipped — don't mention the fix.
- **Common trap:** when drafting from `git log`, every commit looks like a separate bullet — including the "fix X" commits that landed on top of a brand-new feature in the same release window. Before listing a Fixed entry, check whether the thing being fixed was itself added in this same release. If so, drop the fix and fold it into the feature bullet.
- **Example:** if the release adds an in-app browser and also contains a commit "fix: browser pane keyboard handling no longer steals shortcuts", do **not** list the keyboard fix under Fixed. The browser is shipping for the first time, so users will only ever see the working version. The Added entry covers it.
- **Use the entry's release scope.** Include changes within the matching range in **Changelog scope**.
- **Collapse internal iterations within that scope.** Present a feature added and fixed in one range as working. 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.
- **Cut low-signal entries.** "Toolbar buttons have consistent sizing" is too granular. Combine small polish items or drop them.
## Changelog conciseness
@@ -445,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
@@ -476,7 +540,7 @@ Rules:
This returns every distinct GitHub login that authored or co-authored a commit in the PR. Use those logins for attribution. Fall back to `gh pr view N --json author` only if the commits command returns nothing (which should not happen for merged PRs).
When listing PR numbers, `git log --format='%H %s' v<previous>..HEAD | grep -E '\(#[0-9]+\)$'` pulls the PR number out of squash commit subjects.
When listing PR numbers, `git log --format='%H %s' v<previous>..<release-source-sha> | grep -E '\(#[0-9]+\)$'` pulls the PR number out of squash commit subjects.
## Changelog ordering
@@ -490,51 +554,70 @@ Entries within each section (Added, Improved, Fixed) are ordered by user impact:
Before cutting a **stable** release, the release agent reviews the diff as a last line of defence against shipping bugs. Skip this for betas — the beta itself is the smoke test, and gating each beta on a code review defeats the point of using betas as fast release candidates.
Review the diff between the latest release tag and `HEAD`. Focus on:
Review the diff between the latest release tag and the resolved release source. Focus on:
1. **Breaking changes** — especially in the WebSocket protocol, agent lifecycle, and any server↔client contract.
2. **Backward compatibility** — the important direction is old app clients talking to newly updated daemons. Users update desktop and daemon first, then keep running the old app for a while. Flag anything that breaks old clients against new daemons or requires both sides to update in lockstep.
3. **Regressions** — anything that looks like it could break existing functionality.
Use `git diff <latest-release-tag>..HEAD` as the review input. This is a deep sanity check, not a full code review. If anything looks risky, investigate before proceeding and surface the finding to the user.
Use `git diff <latest-release-tag>..<release-source-sha>` as the review input. This is a deep sanity check, not a full code review. If anything looks risky, investigate before proceeding and surface the finding to the user.
## Changelog scope
The changelog always covers **previous-stable-to-`HEAD`**, beta and stable alike:
Changelog scope follows the release being described:
- **Beta release**: the entry covers `previous stable tag → HEAD`. Update the current in-place beta entry; don't start a fresh one per beta.
- **Stable promotion**: the same entry is promoted in place. It still captures the full delta from the previous stable release, not just what changed since the last beta.
- **First beta**: `previous stable tag → release source`
- **Later beta**: `previous beta tag → release source`
- **Direct stable release**: `previous stable tag → release source`
- **Stable promotion**: replace the full beta series with one entry covering `previous stable tag → release source`
Betas are checkpoints along the way; the entry is the single record for the jump from one stable version to the next, and beta users read it in the meantime.
Each beta entry records what its testers receive. Promotion produces the single stable record for the full jump from one stable version to the next.
## Completion checklist
### Beta release
- [ ] Working tree is clean and the intended commit is on `main`
- [ ] Update the in-place beta entry in `CHANGELOG.md` (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 previous-stable-to-`HEAD` diff 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
- [ ] 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
- [ ] `npm run release:beta:patch`, `npm run release:beta:minor`, or `npm run release:beta:next` completes successfully
- [ ] Every GitHub Actions run for the complete release commit and tag is green
- [ ] npm shows the version under the `beta` dist-tag, not `latest`
- [ ] The GitHub prerelease exists with the changelog body and every expected macOS, Linux, Windows, and Android APK asset
- [ ] GitHub `Desktop Release` workflow for the `v*-beta.N` tag is green
- [ ] The GitHub prerelease contains `beta-mac.yml`, `beta-linux.yml`, and `beta.yml`
- [ ] GitHub `Android APK Release` workflow for the same tag is green
- [ ] GitHub `Docker` workflow is green and the versioned beta image is published without moving `latest`
- [ ] GitHub `Release Notes Sync` mirrored the beta entry into the prerelease body
- [ ] EAS `Release iOS Beta` completed its build, TestFlight distribution, external beta group, and Beta App Review path
- [ ] The release heartbeat was created after the tag push and deleted only after every item above passed
### Stable release (or promotion)
- [ ] Run the pre-release sanity check (see above) and address any findings
- [ ] The previous-stable-to-`HEAD` diff is classified as patch or minor, with the target version and rationale approved
- [ ] Ensure the intended release commit is already committed and the git worktree is clean before running any release command
- [ ] The diff from the previousstableto 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). When promoting from beta, overwrite the existing `## X.Y.Z-beta.N` heading in place (heading → `X.Y.Z`, date → promotion day) — do not add a new entry on top of the beta one
- [ ] 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
- [ ] Verify the changelog heading follows strict `## X.Y.Z - YYYY-MM-DD` format
- [ ] Release preparation stayed local until the approved release command pushed the complete branch and tag
- [ ] `npm run release:patch`, `npm run release:minor`, or `npm run release:promote` completes successfully
- [ ] Every GitHub Actions run for the complete release commit and tag is green
- [ ] Move npm's `beta` dist-tag to the new stable version for every published package and verify both `latest` and `beta` resolve to it
- [ ] The published GitHub Release exists with the changelog body and every expected macOS, Linux, Windows, and Android APK asset
- [ ] GitHub `Desktop Release` workflow for the `v*` tag is green
- [ ] The GitHub Release contains `latest-mac.yml`, `latest-linux.yml`, and `latest.yml`
- [ ] GitHub `Android APK Release` workflow for the same tag is green
- [ ] GitHub `Docker` workflow is green and both the versioned and `latest` images are published
- [ ] GitHub `Release Notes Sync` is green and the release body matches the stable changelog entry
- [ ] EAS `Release Mobile` workflow for the same tag is green
- [ ] EAS iOS `build_ios` completes for the same tag
- [ ] EAS iOS `submit_ios` succeeds, uploading the build to App Store Connect/TestFlight
- [ ] EAS iOS `submit_ios_for_review` succeeds, putting the build into App Store review
- [ ] EAS Android `build_android` completes for the same tag
- [ ] EAS Android `submit_android` succeeds, putting the build on its Play Store track
- [ ] The release heartbeat was created after the tag push and deleted only after every item above passed
@@ -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.
@@ -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
@@ -58,7 +71,8 @@ The daemon validates that the epoch is current and the exact source sequence sti
## Resume behavior
Opening, reconnecting, or revisiting after selective-delivery grace fetches the latest tail page.
Opening, reconnecting, or revisiting after a selective-delivery coverage gap fetches the latest tail
page.
Focus alone does not mutate timeline state; the tail response is compared with the local
authoritative range first.
@@ -84,16 +98,22 @@ recomposition while the runtime still owns the same directory snapshot and timel
Removing the host from the registry is the destructive boundary: it stops the runtime and clears the
session and host-scoped setup state together.
The durable replica cache is a display cache, not a synchronization checkpoint. Its timeline record
contains only the focused `agentId` and a truncated item tail. It never persists a cursor, epoch,
older-history availability, authority status, or sync generation because those facts would describe
the complete source dataset rather than the truncated display dataset.
The durable replica cache persists synchronization authority only when it can store the complete
current canonical window losslessly. The stored range describes those exact items: `startSeq` drives
older pagination and `endSeq` drives forward catch-up. Restore paints the items immediately, requests
`after endSeq`, and requests `before startSeq` when the user loads older history.
Restoring that cache produces a painted timeline: the items may render immediately, but the first
daemon timeline request is still `tail`. A successful tail response atomically establishes canonical
items, range, and older-history availability. Live rows received between cache paint and that tail
response stay in the separate live head, do not advance a cursor or trigger gap recovery, and are
reconciled with the authoritative tail and subsequent catch-up.
The first resume request is bounded. If it reports more newer history, fetch one latest bounded tail
instead of replaying every missed page. Live gap recovery still pages forward until current.
If the canonical window exceeds the cache item limit, contains a discontiguous retained range, has a
live head, or includes presentation data the cache cannot encode losslessly, persistence drops the
range and keeps a display-only tail. Restore then uses the ordinary bounded `tail` bootstrap. Never
slice items while retaining the pre-slice range; that falsely certifies discarded source rows.
Live rows received between cache paint and catch-up stay in the separate live head and reconcile with
the authoritative range through the existing forward-page path. The cache does not persist sync
generation or unreconciled local submissions.
Every daemon-derived live item carries its timeline epoch and sequence position. Bootstrap
replacement keeps only positioned rows newer than the page it installs, while unresolved local
@@ -106,13 +126,14 @@ replica cache.
The app chooses one delivery policy from `server_info.features.selectiveAgentTimeline`:
- Selective daemons receive the union of agents visible in every pane. Additions subscribe and
catch up immediately. Every visibility-driven removal, including app backgrounding, stays
subscribed for a 30-second grace period so brief tab, pane, route, and app switches do not repeatedly
unsubscribe and catch up. Losing window keyboard focus does not make a selected pane invisible.
Disconnecting and disposal clear pending grace because the subscription itself no longer exists.
After grace has expired, revisiting a retained timeline displays its cached state immediately and
authoritative catch-up advances it to the current tail.
- Selective daemons receive every agent visible in any pane plus the most recently viewed hidden
agents, up to five subscribed agents. Visible agents always win: if more than five are visible,
they all remain subscribed and no hidden agent does. Switching and app backgrounding preserve
this connection-scoped hot set, so returning to an agent still covered by it needs no catch-up.
Losing window keyboard focus does not make a selected pane invisible. Disconnecting clears hidden
hot agents; reconnect restores the currently visible set before authoritative catch-up. Revisiting
an evicted retained timeline displays its cached state immediately while authoritative catch-up
advances it to the current tail.
- Legacy daemons keep globally streaming agent timelines. Visibility still triggers the existing
authoritative catch-up, but the app does not issue selective-subscription RPCs.
@@ -165,6 +186,11 @@ foreground control ownership remains a separate daemon concern. Cancellation req
with that record rather than in a React component, so an old request cannot clear a newer one. Submissions
remain a separate pre-turn registry and retire on canonical acknowledgement.
Canonical turns and visible responses are different boundaries. System-injected prompts are absent from
the Paseo timeline, so one visible response can span several canonical turns without a user message
between them. Layout and copy group that response together; lifecycle, timing, tool sequences, and exact
fork positions retain the canonical `turnId` boundaries.
The compatibility boundary for older daemons is snapshot normalization: running/idle status becomes an
anonymous active turn or idle state once, and downstream code consumes the same activity shape. The app
does not combine anonymous lifecycle events, timestamps, timeline rows, and resume coverage to infer a
@@ -335,17 +335,18 @@ If we ever need to avoid the transition entirely, store at least the theme prefe
## Runtime Theme Patching For User Preferences
Appearance settings (UI/mono font family, font sizes, syntax-highlight theme) are applied by patching every registered theme at runtime with `UnistylesRuntime.updateTheme(name, updater)` — not by threading preference reads through components. `applyAppearance` in `packages/app/src/screens/settings/appearance/apply-appearance.ts` runs from a `ProvidersWrapper` effect on settings load/change and loops all six theme keys, returning `{ ...theme, fontFamily, fontSize, lineHeight, colors.syntax }`.
Appearance settings (theme, UI/mono font family, font sizes, syntax-highlight theme) are owned by `packages/app/src/appearance`. Its provider subscribes once and synchronizes Unistyles when settings or plugin contributions change. `applyAppearance` patches every key in `REGISTERED_THEMES`, returning `{ ...theme, fontFamily, fontSize, lineHeight, colors.syntax }`.
This works without `useUnistyles()` because every consumer already reads these tokens through `StyleSheet.create((theme) => …)` (or the `withUnistyles`/`uniProps` path for the markdown renderer), so patching the theme repaints tracked views through the native ShadowRegistry with no React re-render.
Gotchas:
- **Patch all themes, not just the active one.** The active theme can change and adaptive mode can flip light/dark; patching every key keeps the active key current and makes ordering vs `setTheme`/`setAdaptiveThemes` irrelevant. The effect depends on the settings values (not on `theme`), so it cannot loop.
- **The reserved plugin keys are the exception to that ordering.** A plugin-contributed theme is rebuilt from its palette, which discards the appearance patch, so the appearance provider writes the matching light or dark slot before applying font and syntax preferences. See [plugins.md](plugins.md).
- **Narrow the discriminated union before spreading.**`updateTheme`'s updater returns the theme union; spreading the union widens `colorScheme` to `"light" | "dark"`, which is assignable to neither concrete member. Branch on `t.colorScheme` so each branch spreads a single narrowed theme type (no `as`).
- **`lineHeight.diff` is the code/diff line-height axis** — it is coupled to the code-font-size control (≈ `codeFontSize * 1.5`). Do NOT use it for prose. Markdown body line-height scales with the UI ramp (`Math.round(theme.fontSize.base * 1.4)`); routing prose through `lineHeight.diff` clips text at small code sizes.
- **`lineHeight.diff` is the code/diff line-height axis** — it is coupled to the code-font-size control (≈ `codeFontSize * 1.5`). Do NOT use it for prose. Markdown body line-height scales with content size (`Math.round(theme.fontSize.content * 1.4)`); routing prose through `lineHeight.diff` clips text at small code sizes.
- **High-churn draft values** (live-while-typing in the appearance preview) bypass the theme: apply them as inline styles marked with `inlineUnistylesStyle` so per-keystroke values don't grow the `#unistyles-web` CSS registry.
- **Mounted parsed content uses `AppearanceStyleBoundary`.**Markdown, syntax-highlighted code, and tool-call detail bodies can contain memoized/custom renderer trees that do not naturally re-run when runtime-patched appearance tokens change. Wrap the parsed surface once with `packages/app/src/components/appearance-style-boundary.tsx`; do not add local "appearance key" props at each callsite.
- **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.
"description":"Paseo: voice-controlled development environment for local AI coding agents",
"keywords":[
@@ -23,6 +23,7 @@
"workspaces":[
"packages/expo-two-way-audio",
"packages/highlight",
"packages/plugin",
"packages/protocol",
"packages/client",
"packages/server",
@@ -46,19 +47,21 @@
"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 +93,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 --",
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.