Compare commits

..

829 Commits

Author SHA1 Message Date
Mohamed Boudra
a2e8a44848 fix(desktop): recognize managed import targets
Desktop ownership is explicit application state. Avoid inferring it from host addresses or launching a second status probe, while retaining validation when an import starts.
2026-07-23 20:36:22 +02:00
Mohamed Boudra
81bffbd01a fix(import): harden the desktop import flow 2026-07-20 15:16:03 +02:00
Mohamed Boudra
9519c5ba99 refactor(import): load the import engine as a dependency 2026-07-20 15:05:57 +02:00
Mohamed Boudra
194822b939 fix(server): authorize active workspace config 2026-07-19 07:28:45 +02:00
Mohamed Boudra
2505075beb fix(migrate): reject nested command substitutions 2026-07-19 06:30:11 +02:00
Mohamed Boudra
0c3c61be94 fix(migrate): refresh managed checkout config 2026-07-19 06:09:20 +02:00
Mohamed Boudra
7046b118e9 fix(migrate): preserve adopted workspace config 2026-07-19 05:39:59 +02:00
Mohamed Boudra
3fed2f2cd9 fix(migrate): validate source refs and expansions 2026-07-19 05:02:59 +02:00
Mohamed Boudra
234f0d9b75 test(migrate): cover deleted checkout recreation 2026-07-19 04:28:05 +02:00
Mohamed Boudra
548e15c177 fix(migrate): preserve direct port arithmetic 2026-07-19 03:53:07 +02:00
Mohamed Boudra
6240b2d17c test(server): tolerate delayed Windows handle release 2026-07-19 03:39:32 +02:00
Mohamed Boudra
4e99e4c18c fix(server): release workspace watchers on shutdown 2026-07-19 03:06:48 +02:00
Mohamed Boudra
2f07c5918f test(migrate): compare checkout identities portably 2026-07-19 02:47:23 +02:00
Mohamed Boudra
3e6061b169 fix(client): normalize checkout branch refs 2026-07-19 02:27:46 +02:00
Mohamed Boudra
4ed944db2f fix(migrate): validate live source state 2026-07-19 02:12:29 +02:00
Mohamed Boudra
af244d0f02 fix(migrate): share service command semantics 2026-07-19 02:01:39 +02:00
Mohamed Boudra
f2124f8cf9 fix(migrate): normalize source references 2026-07-19 01:50:04 +02:00
Mohamed Boudra
152cdcabad fix(migrate): preserve Windows shell semantics 2026-07-19 01:39:15 +02:00
Mohamed Boudra
44da3cdf05 fix(migrate): preserve consistent source state 2026-07-19 01:33:48 +02:00
Mohamed Boudra
a8c6c2f4bc fix(migrate): gate automatic source discovery 2026-07-19 01:21:02 +02:00
Mohamed Boudra
faccbaae59 fix(migrate): validate workspace connection targets 2026-07-19 01:10:18 +02:00
Mohamed Boudra
e61c06706c fix(migrate): preserve source command semantics 2026-07-19 01:03:00 +02:00
Mohamed Boudra
c54ddfd07f fix(migrate): preserve checkout and shell semantics 2026-07-19 00:48:41 +02:00
Mohamed Boudra
ddaaaa27be fix(desktop): flush migration output before completion 2026-07-19 00:29:50 +02:00
Mohamed Boudra
fbd02437dc Merge main into project migration branch 2026-07-19 00:28:33 +02:00
Mohamed Boudra
b447a650a7 feat(desktop): migrate existing projects and worktrees 2026-07-19 00:22:17 +02:00
Mohamed Boudra
c9bcfa7638 Use safer automatic approval modes by default (#2213)
* feat(providers): default to safer automatic approvals

* fix(providers): preserve defaults on older Codex versions

* test(providers): cover Claude automatic approval default

* fix(providers): preserve Claude defaults on cloud transports

* fix(providers): resolve defaults from launch capabilities
2026-07-18 23:03:39 +02:00
Mohamed Boudra
0cfb9b6b94 Keep sidebar pins visible while reopening (#2210)
* fix(app): keep sidebar pins visible while reopening

Hidden sidebars stop consuming live workspace updates but retain their last rendered entries so the opening animation never exposes an empty list.

* test(app): reuse sidebar pin flow
2026-07-18 22:26:31 +02:00
Mohamed Boudra
8cc2ae0ba4 Resume collected agents before pane actions (#2209)
* fix: resume collected agents before pane actions

* fix(server): keep archived agents closed during pane actions

An archive could win while a collected agent was resuming, then lose when the provider runtime registered. Recheck persisted archive state after registration and close the resumed runtime before any mutation runs.

* fix(server): fence archived agents after shared resume

Protected pane actions could join a resume started by an ordinary loader and skip the archive fence. Recheck persisted lifecycle state for every protected caller so archive always wins before mutation.
2026-07-18 22:15:27 +02:00
Mohamed Boudra
99dc8ddda5 Free resources from idle agents automatically (#2203)
* fix(server): release resources held by idle agents

Keep unarchived agents resumable while closing their provider runtimes after two minutes. Active agent schedules keep runtimes resident.

* fix(server): preserve resumable agent state

* fix(server): resume agents when listing commands

* fix(server): preserve collected agent interactions
2026-07-18 21:21:05 +02:00
Mohamed Boudra
5ea311f243 feat(app): restore recent chat while reconnecting (#2206)
Keep a small non-authoritative view of the last focused chat so the app can paint it before the daemon finishes revalidation. Bound persistence to one focused agent per host and evict whole host entries to limit storage and serialization work.
2026-07-19 03:08:46 +08:00
Mohamed Boudra
2185779d6c fix(settings): highlight changes that apply next turn (#2201)
Permission and thinking changes made during an active turn do not affect that turn. Use setting-specific warnings so the timing is harder to miss.
2026-07-18 18:45:23 +02:00
Mohamed Boudra
e0e50c9a8e fix(app): preserve persisted theme styles after startup
Module-level style composites could materialize the temporary adaptive theme before persisted settings loaded. Keep Unistyles reads in render and guard against eager module-scope access.
2026-07-18 17:45:02 +02:00
Mohamed Boudra
c0622a7046 feat(website): show real app in homepage hero 2026-07-18 17:43:38 +02:00
Michał Kędrzyński
45bbb973a3 Switch models from the Command Center (#2147)
* Switch models from the Command Center

Add a model switcher to the Command-K Command Center. Typing surfaces a
flat, filterable list of "Model › Provider › Name" breadcrumb rows with
provider icons:

- Running agent: its own provider's models (a live agent can't change
  provider); selecting calls setAgentModel.
- New draft tab: every available provider's models in one flat list;
  selecting sets provider + model on the draft via a focused-draft
  controller published to a global store (the draft form state is local
  to the composer subtree and otherwise unreachable from the global
  Command Center).

Models only appear once the user starts typing, so the default palette
view is unchanged. Reuses useProvidersSnapshot and the existing
setAgentModel RPC — no protocol changes.

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

* Update packages/app/src/components/command-center.tsx

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* Update packages/app/src/hooks/use-command-center.ts

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* refactor(app): make Command Center extensible

Let focused features register stable actions while the palette owns search, selection, and a single virtualized result projection.

* fix(app): save model preference after agent switch

Persist the shared model choice only after the daemon confirms the live agent switched successfully.

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-07-18 16:54:41 +02:00
Mohamed Boudra
7917532716 Keep focused agent timelines live and catch up instantly (#2196)
* fix(app): keep focused agent timelines live

Window focus was conflated with app visibility, so switching OS windows could remove the selected timeline subscription. Separate those signals, grace every visibility-driven removal, and cover retained history until authoritative catch-up completes.

* fix(app): keep timeline catch-up failures non-blocking

* fix(app): surface timeline catch-up failures

* fix(app): preserve file preview refocus

* fix(app): preserve optimistic catch-up flow
2026-07-18 16:30:59 +02:00
Matt Van Horn
fcaa84f0e4 fix(omp): accept thinkingLevel "max" when importing OMP sessions (#2191)
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
2026-07-18 13:44:34 +00:00
Jason@HND
b4518cbf33 fix(server): remove wall-clock timeout for Pi compact RPC (#2181)
Pi compact is a blocking LLM summarization job that often exceeds the
default 30s control-plane timeout on long sessions, causing a false UI
error while the real compact continues. Wait for the RPC response,
process exit, or session close instead.

Also cover the no-timeout lifecycle: null timeout still rejects on
close, and compact waits past 30s for a late success response.

Closes #1946
2026-07-18 21:20:03 +08:00
Mohamed Boudra
72752b7db6 Document workspace-first agent automation (#2192)
* docs: explain workspace-first automation

* docs: make automation examples self-contained
2026-07-18 15:18:05 +02:00
Mohamed Boudra
05d1f838d8 fix(composer): keep message input visible after dictation (#2194)
The voice overlay transition could retain stale native opacity after the app was backgrounded, leaving the mounted composer transparent. Derive both surfaces directly from voice state so they cannot disagree after resume.
2026-07-18 15:15:25 +02:00
nllptrx
c97f823236 Fix commit-aware PR resolution across forges 2026-07-18 14:21:10 +02:00
Mohamed Boudra
ffe76a7e57 Make workspace, agent, and schedule automation consistent (#2186)
* feat(workspaces): align agent and schedule automation

Make workspace identity the shared placement boundary for MCP and CLI, while caller identity determines parentage. Keep heartbeats minimal and cron-based without changing legacy rolling interval semantics.

* test(cli): expect canonical schedule cadence

* fix(automation): preserve workspace and schedule compatibility

* fix(automation): preserve compatibility edges

* fix(workspaces): align MCP lifecycle resolution

* fix(workspaces): preserve creation intent

* fix(workspaces): preserve branch and schedule identity
2026-07-18 14:04:48 +02:00
Mohamed Boudra
98f6611362 Fix duplicated and out-of-order agent chat messages (#2185)
* fix(app): keep agent timelines ordered during catch-up

Projected catch-up pages can overlap live rows and arrive after optimistic
prompts. Reconcile canonical projections before the existing stream reducer,
and retain selective subscriptions briefly across quick view switches.

* refactor(app): make optimistic catch-up consumption explicit

* fix(app): preserve catch-up message order

* fix(app): isolate delayed catch-up history

* fix(app): anchor catch-up before live turns

* fix(app): stage complete catch-up history

* fix(app): keep timeline arrays Hermes-safe

* refactor(app): preserve prompt positions during catch-up
2026-07-18 13:49:32 +02:00
paseo-ai[bot]
99da5736db fix: update lockfile signatures and Nix hash [skip ci] 2026-07-18 10:51:30 +00:00
Mohamed Boudra
d9abac0f8e chore(server): update Claude Agent SDK (#2189) 2026-07-18 12:43:14 +02:00
Mohamed Boudra
745e8afe45 Make keyboard shortcuts searchable (#2160)
* feat(app): make keyboard shortcuts searchable

* test(app): cover shortcut search empty state

* fix(app): index shortcut modifier aliases
2026-07-18 11:54:49 +02:00
Mohamed Boudra
b4a5b6a3ff Add non-Git projects across filesystem mounts (#2187)
* fix(projects): allow non-git folders across mounts

Treat failed Git worktree discovery as non-Git so ordinary directories remain addable. Preserve the underlying diagnostic as a structured warning instead of failing project creation.

* fix(projects): preserve discovery warnings

Propagate checkout context through the remaining worktree discovery calls so fail-open classification retains its structured warning.
2026-07-18 11:49:11 +02:00
Slava Goltser
a1de743ef6 fix(app): align thinking section scroll layout with other detail sections (#1884)
* feat(app): make thinking sections scrollable when expanded

* fix(app): align thinking section scroll layout with other detail sections
2026-07-18 03:07:44 +08:00
Mohamed Boudra
a414f8ea85 Connect your Paseo daemon to Hub (#2035)
* feat(hub): connect daemons to Paseo Hub

Make Hub an explicit daemon-owned relationship with local-only management and scoped access to Hub-owned executions.

* fix(hub): harden relationship boundaries

* fix(hub): harden relationship lifecycle

* fix(hub): isolate CLI test entrypoint

* fix(hub): run CLI tests from workspace source

* fix(hub): settle failed relationship connections

* fix(hub): resume interrupted owned turns

Provider session rehydration does not continue foreground work lost during daemon shutdown. Persist narrowly scoped Hub execution intent and replay only an interrupted running initial turn.

* fix(hub): harden relationship lifecycle

Keep optional Hub authority from blocking daemon startup, revoke ambiguous enrollments durably, and close owned agents when their relationship no longer exists. Reject remote CLI connect targets before transmitting enrollment authority.

* fix(hub): stop replaying interrupted turns

Daemon restart cannot safely guarantee prompt idempotency across providers. Persist the normal closed session state while retaining Hub relationship, execution, and agent identity.

* fix(hub): fail creates when prompts cannot start

* fix: make Hub lifecycle cleanup deterministic

* fix(hub): preserve fresh enrollment authority

* fix(hub): contain enrollment retry failures

* fix(hub): reject invalid socket transport URLs

* fix(hub): bind socket transport to Hub authority

* fix(hub): close relationship lifecycle gaps

* test(hub): stabilize lifecycle coverage on Windows

* fix(hub): close execution authority races

* fix(hub): return relationship command errors

* fix(app): preserve workspace navigation compatibility

* fix(hub): correct relationship trust boundaries

Authenticated daemon sessions own relationship management regardless of transport. The separate Hub session remains operation-allowlisted, rejects malformed execution inputs, and uses bounded outbound handshakes.

* refactor(hub): authorize execution through sessions

* fix(hub): validate persisted origins

* fix(hub): retain local execution grants

* fix(hub): enforce session scope boundaries

Make session authority explicit and mutable without adding scope negotiation. Fence persisted Hub scopes and retire in-flight execution authority during cleanup and re-enrollment.

* fix(server): preserve main session compatibility
2026-07-17 20:20:57 +02:00
Mohamed Boudra
39cb3dbb9c Treat every added folder as an independent project (#2098)
* fix(projects): detect when projects become Git repositories

Project identity was coupled to Git placement, while non-Git roots were dropped by session-scoped observation. Keep identity tied to the selected root and observe Git transitions daemon-wide so empty projects update without rehoming workspaces.

* fix(projects): preserve metadata across Git read failures

Refresh archived workspace facts before persistence and treat only confirmed non-repositories as non-Git so transient failures cannot rewrite stored project metadata.

* fix(projects): close project update races

* fix(projects): refresh checkout metadata when reopening folders

Persist worktree ownership separately from workspace kind and gate exact-root project creation on stable host identity. Refresh active and archived records so missed Git transitions cannot return stale checkout descriptors.

* test(projects): accept Windows aliases for repaired roots

* test(server): retry transient Windows cleanup locks

* fix(projects): propagate project metadata refreshes

Project-only Git transitions now fan out workspace updates for legacy clients. Explicit project creation refreshes stored kind, and equivalent cwd spellings reuse existing workspace records.

* fix(projects): refresh worktree source project kind

* fix(projects): preserve exact-folder runtime isolation

* fix(projects): preserve exact cwd across worktree lifecycle

* fix(projects): harden exact-folder worktree flows

* fix(projects): derive exact cwd from matched path identity

* fix(projects): preserve nested worktree lifecycle

* refactor(projects): centralize workspace placement

* test(e2e): verify isolated server ports

* fix(projects): preserve placement reshape compatibility

* fix(projects): validate created worktree placement

* fix(projects): preserve placement through workspace lifecycle

Archive and recovery were rediscovering or guessing checkout roots instead of consuming the persisted workspace placement. Make the record authoritative for exact cwd, backing worktree, and source repository, and classify stale paths before Git reconciliation.

* fix(worktrees): preserve source checkout root

Convert Git's common administrative directory back to the source checkout root when legacy archive placement is reconstructed.

* fix(worktrees): compare filesystem identities

Resolve discovered worktrees and teardown locations through the shared realpath-aware matcher so Windows short and long path spellings cannot split placement identity.

* fix(worktrees): centralize path containment

Route worktree ownership, listing, resolution, and deletion through the realpath-aware containment primitive so Windows path aliases cannot be filtered by an earlier raw prefix check.

* fix(worktrees): remove duplicate ownership discovery

* test(e2e): isolate daemon restart ownership

* fix(worktrees): make lifecycle operations transactional

* fix(workspaces): share git watches by cwd

* test(worktrees): make teardown proof portable

* fix(sync): integrate project updates with directory owner

* fix(workspaces): close reconciliation edge cases

* refactor(sync): remove duplicate project reconciliation

Keep workspace and project deltas behind the host directory transaction, with owner-boundary coverage for snapshot replay and queued full reconciliation.

* fix(sync): buffer project updates before hydration

Start the workspace transaction with the online connection epoch so project broadcasts cannot publish a partial directory before the authoritative snapshot commits.
2026-07-17 19:30:17 +02:00
Mohamed Boudra
4d2acfbeb5 fix(server): reject imported port offsets 2026-07-17 18:28:10 +02:00
Mohamed Boudra
d6bc201f46 fix(app): refresh import availability previews 2026-07-17 18:10:55 +02:00
Mohamed Boudra
5e75e463ec fix(settings): expose remaining import errors 2026-07-17 17:47:16 +02:00
Mohamed Boudra
35d4ae201a fix(settings): tighten conductor service imports 2026-07-17 17:21:23 +02:00
Mohamed Boudra
1977d330ed Reduce workspace, agent, and chat sync traffic (#2028)
* feat(sync): keep live data scoped and current

Only viewed chats receive live timeline rows, while directory state now uses one subscribed bootstrap followed by ordered deltas. Legacy clients and daemons retain their existing behavior through centralized compatibility gates.

* fix(sync): preserve selective delivery boundaries

Keep selective timeline capability app-owned, union viewed sets across shared sockets, and reconcile directory side effects from accepted state. Visibility and archive suppression now follow their existing authoritative boundaries.

* test(app): align browser fixtures with runtime contracts

* fix(sync): preserve mixed-client delivery guarantees

* fix(sync): finish paged history before advancing

* fix(sync): replay live deltas after refresh failures

* test(server): model socket capability lookup

* test(app): use platform shortcut for split-pane coverage

* fix(app): keep hidden timelines dormant

* test(app): normalize split-pane shortcut setup

* fix(app): preserve sync state across retries

* fix(app): preserve background sync state across races

* fix directory bootstrap reconciliation edge cases

* fix selective timeline compatibility races

* fix superseded directory sync races

* fix(sync): centralize directory replica ordering

Own agent and workspace refresh transactions in HostRuntime so reconnect epochs, buffered updates, and lifecycle invalidation share one ordering boundary. Route timeline metadata and mixed-capability stream delivery through their authoritative runtime sources.

* fix(app): keep timeline requests runtime-scoped

* fix(app): close directory bootstrap races
2026-07-17 17:03:34 +02:00
Mohamed Boudra
b93d326fb2 fix(settings): preserve conductor migration context 2026-07-17 17:00:30 +02:00
Mohamed Boudra
9505e43925 fix(settings): report remaining import gaps 2026-07-17 16:42:07 +02:00
Mohamed Boudra
2f0e3451de fix(settings): wait for import preview before setup callout 2026-07-17 16:22:10 +02:00
Mohamed Boudra
28cac5859a fix(settings): keep project imports current and complete 2026-07-17 15:56:04 +02:00
Mohamed Boudra
6c99efae52 fix(android): give release builds more memory 2026-07-17 15:48:06 +02:00
Mohamed Boudra
59d9a25a0d fix(settings): handle remaining import edge cases 2026-07-17 15:32:27 +02:00
paseo-ai[bot]
6f753a142d fix: update lockfile signatures and Nix hash [skip ci] 2026-07-17 13:18:56 +00:00
Mohamed Boudra
60462337b6 fix(settings): close project import edge cases 2026-07-17 15:11:04 +02:00
Mohamed Boudra
0bec06c2db chore(release): cut 0.2.0-beta.1 2026-07-17 15:10:06 +02:00
Mohamed Boudra
c0f80e2477 Prepare 0.2.0 beta changelog 2026-07-17 15:07:21 +02:00
Mohamed Boudra
293f55afc4 Update ACP provider catalog versions 2026-07-17 15:07:14 +02:00
Mohamed Boudra
df2b7cab46 Document release version classification 2026-07-17 15:07:08 +02:00
Mohamed Boudra
f44ef807ef fix(settings): harden conductor imports 2026-07-17 14:48:54 +02:00
Mohamed Boudra
f030ddefb1 refactor(settings): make project imports pluggable 2026-07-17 14:42:02 +02:00
Mohamed Boudra
dfada2a556 fix(app): update provider icon 2026-07-17 14:03:43 +02:00
Mohamed Boudra
263ccc2a19 Merge branch 'main' of github.com:getpaseo/paseo 2026-07-17 13:06:31 +02:00
Mohamed Boudra
388f1d426c Keep agent browser tabs connected across workspace switches (#2156)
* fix(desktop): keep browser tabs connected across workspaces

Electron can replace a guest WebContents when a retained browser tab is reparented. Re-register each attachment and keep background actionability checks running so agent browser tools retain the tab through workspace eviction.\n\nCover the full app, daemon, Electron, and MCP path in the existing desktop CI job.

* fix(desktop): launch Electron E2E reliably on Linux

CI Electron must receive --no-sandbox before the app starts because the hosted runner cannot use Electron's bundled SUID sandbox helper. Forward explicit dev-runner arguments and fail readiness waits as soon as a child exits.

* fix(desktop): preserve active browser on repeated registration

* fix(desktop): keep browser keyboard attachment idempotent

* fix(desktop): wait for E2E bridge readiness

* fix(desktop): close E2E logs after output drains
2026-07-17 19:02:09 +08:00
Mohamed Boudra
a7cbf4f61d fix(app): make sidebar reordering respond immediately
Mouse input inherited the touch hold delay. Split activation by input so mouse dragging begins after deliberate movement while touch retains long-press arbitration.
2026-07-17 13:00:47 +02:00
Mohamed Boudra
266d54463b feat(app): highlight sidebar resize handles 2026-07-17 12:27:15 +02:00
Mohamed Boudra
f7a6548b68 fix(app): refresh stale config import apply 2026-07-17 11:33:56 +02:00
Mohamed Boudra
ab9c9b96a7 fix(server): normalize conductor import paths 2026-07-17 11:31:52 +02:00
Mohamed Boudra
557fc42c89 feat(server): include daemon version in logs (#2155)
Attach the running release to every entry so log history can show when daemon upgrades took effect.
2026-07-17 11:17:21 +02:00
Mohamed Boudra
8fe11e3bb0 feat(settings): import existing project setup 2026-07-17 11:15:10 +02:00
Mohamed Boudra
bce2c50b9e Keep terminal sizing reliable through focus changes (#2154)
* fix(terminal): retry size claims until sent

A focus resize could consume its once-per-focus latch before the renderer handed the size to the client. Keep the claim pending across visibility, connection, and renderer readiness changes, and only commit it after the resize is sent.

* fix(terminal): keep disconnected claims retryable

A retained runtime client can outlive its active connection. Require the connection at delivery time so a delayed resize callback cannot commit a claim that was never sent.

* test(terminal): await PTY resize observation

Prove the terminal starts at 80x24 while blurred, then probe through the daemon until its own reported size matches xterm after focus returns. This avoids racing a one-shot stty sample against the asynchronous resize claim.
2026-07-17 10:07:50 +02:00
Mohamed Boudra
70472dc945 Always install the newest eligible desktop update (#2149)
* Reapply "Always revalidate desktop updates before install"

This reverts commit 623c05aa4d.

* fix(desktop): make update revalidation safe on quit

Updater-triggered quits now bypass normal quit handling, and manifest revalidation gives up after five seconds without allowing a late install.

* fix(desktop): preserve fail-closed update installs

Keep cached updates deferred when quit-time validation is offline, and report validation timeouts separately from superseded releases.

* fix(desktop): preserve updater quit behavior

Automatic installs remain silent without relaunching, updater handoff is bounded, and background preparation failures remain visible without pinning manifest checks.

* fix(desktop): keep AppImage updates manual

Preserve the AppImage safety exemption by skipping ordinary quit-time installation while retaining the explicit Update now path.

* fix(desktop): serialize update preparation

* fix(desktop): recognize macOS updater quit handoff
2026-07-17 10:06:32 +02:00
nllptrx
a8ebd390fa feat(forge): pluggable forge abstraction + GitLab and Gitea/Forgejo/Codeberg (#1913)
* refactor(forge): forge-neutral foundation (GitHub-only)

Decouple git-hosting from GitHub behind a neutral abstraction (issue #1616), GitHub-only for now; existing GitHub behaviour is unchanged.

- Forge manifest, neutral ForgeService contract, forge registry + resolver, and a client forge-module registry.
- GitHub code renamed to the neutral shape; PR/Issue attachment wording preserved.
- forge.search.response enums parse tolerantly (unknown kind/auth state degrade instead of breaking the client).
- createPullRequest reports typed CLI/auth errors instead of a generic message.
- forge-resolver host/remote caches are LRU-bounded.
- Forge host trust is explicit: only a known cloud host or a CLI-authenticated host is ever talked to; an unauthenticated GitHub Enterprise host fails resolution instead of routing to github.com.
- Docs: forge-providers guide, glossary and i18n forge-copy conventions, architecture and rpc-namespacing terminology.
- Vitest React Native mocks (unistyles, svg, linking, lucide) consolidated into shared aliased test-stubs.

* feat(forge): GitLab adapter, forge-aware UI, pipelines and approvals

GitLab adapter over the glab CLI on the neutral contracts: MR status, forge-aware UI, pipeline tree, and N-of-M approvals.

- threadIsResolved is part of the neutral timeline item.
- Pipeline load failures show an error instead of an empty section.
- Manual pipeline jobs render as pending.
- Fork/detached MR head pipelines are fetched by MR iid (glab ci get --merge-request).

* feat(forge): Gitea family adapter (Gitea, Forgejo, Codeberg)

One adapter over the tea CLI serving Gitea, Forgejo, and Codeberg on the neutral contracts.

- CI status aggregates commit statuses and Actions runs together.
- Gitea's terminal "warning" state maps to failure on server and client.
- Gitea Actions check details are reachable from the PR pane by workflowRunId.

* refactor(forge): localize compatibility handling

* test(forge): expect normalized GitLab facts

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-07-17 15:03:26 +08:00
Mohamed Boudra
04d1ebdce0 Keep browser input from submitting Paseo prompts (#1982)
* fix(desktop): keep browser input out of the composer

Unhandled webview keys could be redispatched into the active host window, allowing agent Enter to submit a draft prompt. Give pages first refusal for ordinary shortcuts and contain automation at the guest boundary.

* fix(desktop): tighten browser shortcut validation

* fix(desktop): respect browser shortcut ownership

* fix(desktop): retain browser keys across windows

* fix(desktop): scope browser webviews by host

* Reshape browser shortcut ownership

Use the browser webview registry as the single guest identity authority, scope browser operations to their host window, publish chord continuations only while pending, and restore focus to the originating browser after command center dismissal.

* Fix host-scoped browser shortcut follow-ups

* Fix browser keyboard review follow-ups

* Fix browser keyboard lifecycle regressions

* Fix browser shortcut review regressions

* Fix desktop browser review regressions

* Fix browser shortcut frame regressions

* Fix rebase integration regressions

* Preserve browser-native shortcut ownership

* Isolate browser shortcuts and automation by context
2026-07-17 14:29:19 +08:00
Christoph Leiter
9f5f5fce62 Fix terminal resize race (#2059) 2026-07-16 23:47:09 +02:00
Mohamed Boudra
a622860a3e Keep workspace focus mode scoped and easy to exit (#2151)
* fix(workspace): keep focus mode scoped and easy to exit

Route focus mode through the active workspace so persisted state cannot hide chrome on settings or other screens. Add a visible exit control and keep desktop window chrome aligned and visually quiet.

* fix(app): clarify muted chrome semantics
2026-07-16 23:46:24 +02:00
Ethan Greenfeld
d2308f4835 feat(omp): add native OMP provider (#2067)
Pi and OMP share only the JSONL child-process transport while retaining provider-owned launch, RPC, runtime, session, history, and permission behavior. Removes captured fixtures in favor of typed harnesses and real-provider coverage.

Closes #2006
Closes #2060
2026-07-16 23:20:59 +02:00
paseo-ai[bot]
d9a0b3e8d8 fix: update lockfile signatures and Nix hash [skip ci] 2026-07-16 21:10:04 +00:00
Mohamed Boudra
6db7e53b6e Record the 0.1.110 ACP hotfix release (#2150)
Sync the 0.1.110 release metadata and changelog back to main.
2026-07-16 23:01:31 +02:00
Mohamed Boudra
737f30c339 Make commit history easier to scan and review (#2146)
* feat(app): improve commit history and diff review

* fix(app): keep commit timestamps current

* fix(app): pause hidden commit clocks

* fix(app): reopen commit diff in persistence test
2026-07-17 04:15:04 +08:00
Mohamed Boudra
60855a0f3a feat(website): group changelog patches by minor release
Keep each patch dated and directly linkable while reducing top-level changelog noise.
2026-07-16 22:11:02 +02:00
Mohamed Boudra
623c05aa4d Revert "Always revalidate desktop updates before install"
This reverts commit 7d80fdfd12.
2026-07-16 21:20:57 +02:00
Mohamed Boudra
d5baf1a7e6 fix(acp): keep foreground agents running (#2148)
ACP setup and out-of-prompt notifications do not define a turn lifecycle. Treating them as autonomous turns could complete the active run while its prompt was still streaming.
2026-07-16 20:49:00 +02:00
Mohamed Boudra
a1cd50c2ae Reimport archived sessions into the current workspace (#2123)
* fix(sessions): reimport archived sessions in their workspace

Archived agent records were treated as active imports, hiding their provider sessions permanently. Workspace-originated imports also discarded their workspace identity and created a duplicate workspace.

* fix(sessions): validate archived session restores

Restore archived imports under their existing Paseo agent identity, reject stale workspace ownership, and gate workspace targeting when the host cannot honor it.

* fix(sessions): roll back failed archived imports

If provider resume or history hydration fails, close any partial runtime, re-archive the provider session, and restore the original stored agent record.

* fix(sessions): validate restored import ownership

* fix(sessions): serialize concurrent restores

* fix(sessions): harden archived import recovery

* fix(sessions): validate archived import placement

* refactor(sessions): centralize provider session imports

Keep workspace placement rollback and stored-agent activation behind their existing owners so Session remains the wire boundary.
2026-07-17 02:25:50 +08:00
Mohamed Boudra
7d80fdfd12 Always revalidate desktop updates before install 2026-07-16 20:17:58 +02:00
Mohamed Boudra
04c71c5890 Style the 0.1.109 update notice as a callout 2026-07-16 20:01:59 +02:00
Mohamed Boudra
721ef03779 Warn desktop users about the 0.1.108 update 2026-07-16 19:54:31 +02:00
Mohamed Boudra
ccf29f4c50 Fix sign-in popups in the desktop browser (#2137)
* fix(desktop): keep sign-in popups connected

Turning every window-open request into a workspace tab severed the opener relationship required by popup authentication flows. Keep script-created and POST-backed opens as secured child windows while ordinary links continue to use workspace tabs.

* fix(desktop): keep Shift-click links in workspace tabs

Electron reports both popup windows and Shift-clicked links as new-window. Use popup features or a named target to preserve script popups without bypassing Paseo tab state for ordinary links.

* fix(desktop): tighten popup profile handling

* fix(desktop): preserve popup feature intent

* fix(desktop): match browser popup heuristics
2026-07-16 18:45:32 +02:00
adradr
0d3b717cf3 Git commit history (#1534)
* feat(app): add fileView preference for changes panel

* feat(app): add buildDiffTree util for changed-files tree

* feat(app): add changed-files tree directory row

* feat(app): changed-files tree view mode in changes panel (#117)

* feat(protocol): add checkout.commits.list RPC + commitsList feature flag

* feat(server): list branch commits ahead of base with on-remote flags

* feat(server): handle checkout.commits.list RPC and advertise capability

* feat(client): checkout.commits.list method + useCommitsQuery hook

* feat(app): per-commit inline view with local-vs-remote markers (#117)

* feat(protocol,server): per-commit file diff RPC (checkout.commits.file_diff)

* feat(app): open per-commit file diff on click (#117)

* refactor: surface baseRef + commits loading/error, drop dead depth field

* fix(app): flip commit local/remote dot — local hollow, remote filled

* feat(app): list/tree view for expanded commit file list (#117)

* fix(app): syntax-highlight per-commit file diff to match Changes view

* feat(app): draggable resize between commits and diff sections

* refactor(app): dedupe wrap-text helpers into diff-highlighted-text

* fix(app): clean up commits resize drag on unmount + a11y label

* fix(app): collapse commits section by default

* fix(app): render per-commit file diff with the shared Changes line renderer

* perf(app): memoize shared diff line row; drop redundant DiffLineView wrapper

* refactor(app): extract shared DiffFileBody; render commit file diff through it

* fix(app): hide inline-comment affordance in per-commit diffs (no reviewActions)

* feat(app): move commits to a resizable bottom drawer in the Changes panel

* feat(review): commitSha scoping for per-commit review drafts + attachment

* feat(app): per-commit inline comments wired through to the composer (#117)

* refactor(app): own commit file-diff open state in CommitFileList (drop reset effect)

* refactor(app): colocate diff-render cluster under git/diff-file-body/

* feat(app): add diff tab target kind (working/commit diff tabs)

* feat(app): useDiffFiles hook unifying working + commit diff targets

* feat(app): diff tab panel rendering working/commit diffs

* feat(app): open a commit diff tab on commit click; drop per-commit drawer/file list

* feat(app): open/scroll working diff tab on changed-file click

* fix(app): keep commit diff tabs ephemeral; align working diff whitespace

* feat(app): collapsible file sections in the diff tab, collapsed by default

* feat(app): make the on-remote commit dot more subtle

Dims the filled green remote dot (row + legend) to ~0.55 opacity so the
local-only ring stays the state that draws the eye.

* Reshape commit diffs around the existing Changes view

* Fix diff tab migration complexity after rebase

* Address diff tab review findings

* Clean up diff tab review interfaces

* Collapse commit diffs into the existing view

* Load commits when expanded

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-07-16 17:36:41 +02:00
Mohamed Boudra
5da6548aff Improve the archived workspace restore flow (#2002)
* fix(app): restore missing workspaces from History

Workspace and agent archival are separate lifecycles. Carry restore intent from History so closed agents can reopen archived workspaces without changing ordinary navigation.

* fix(app): wait for workspace hydration before restore

* fix(app): preserve History agent unarchive

* fix(app): reopen archived History agents outside active directory

History entries are not merged into the active session maps. Carry the row archive state through explicit restore intent so live workspaces still reopen without broadening ordinary navigation.

* fix(app): ignore stale History archive state for active agents

A cached History row can outlive a successful reopen. When the workspace exists, live session state now wins so an active agent is not interrupted by another refresh.

* fix(app): reopen every selected archived History agent

History entries without workspace IDs returned before restoration, and a second archived agent sharing an in-flight workspace restore was dropped. Preserve both agent-only and deferred reopen intent.

* fix(app): require explicit workspace recovery

* fix: preserve workspace recovery contracts

* fix: preserve workspace recovery compatibility

* fix: preserve recovered workspace session state

* fix: preserve terminal navigation timing

* fix(server): preserve successful workspace recovery

* fix: preserve workspace recovery intent
2026-07-16 17:22:37 +02:00
Mohamed Boudra
d42ab91971 Show agent history errors without a one-minute wait (#2124)
* fix(client): fail invalid RPC responses immediately

Correlated responses that failed schema validation were discarded before request matching, leaving callers blocked until the RPC timeout. Preserve only the raw correlation identity at the boundary so the matching request fails with a protocol error.

* test(client): preserve invalid response diagnostics

* fix(client): ignore invalid correlated progress events
2026-07-16 16:44:01 +02:00
Mohamed Boudra
e528a0db06 Remove custom providers from settings (#1951)
* feat(providers): remove custom providers from settings

Add a destructive removal flow so mistaken custom providers can be deleted from config.json instead of only disabled.

* test(app): cover provider removal with e2e

Move provider removal coverage out of mocked component tests and into the real Settings flow.

* fix(providers): keep removal live after config updates
2026-07-16 16:11:51 +02:00
Mohamed Boudra
6aba0370ae Make remote daemon update failures actionable (#2120)
* fix(settings): show daemon update failures

React Native web ignores Alert.alert, so failed remote updates reset to an enabled button without any user-visible explanation. Keep updater progress and failure as explicit rendered state, including the daemon's error, and cover the real browser-to-daemon failure path.

* test(settings): harden daemon update regression

* fix(settings): disable Desktop-managed daemon updates

Desktop-bundled daemons advertised npm self-update even though the install-origin checks always rejected it. Report Desktop ownership to clients, render actionable guidance, and refuse stale requests before touching npm.
2026-07-16 21:04:31 +08:00
Mohamed Boudra
f4509fe044 Open files in more installed editors (#2119)
* feat(desktop): open files in more installed editors

Detect bundled app commands as well as PATH launchers and preserve file positions through editor-specific launches.

* fix(desktop): preserve editor target compatibility

Keep existing file-manager preference ids, retain project context for file launches, and recognize Windows 64-bit IDE commands.
2026-07-16 14:39:55 +02:00
Jason@HND
3e8dce7d7c Hide browser shortcuts outside the desktop app (#2116)
* fix: hide browser pin on non-Electron web

Browser is desktop-only, but the pinned shortcut bar still rendered a
New browser pin on web where createBrowser is a silent no-op.

Skip browser pins in usePinnedLaunchers when not Electron, and drop
browser from default pinned targets so new users only get terminal.

* fix(app): preserve browser pin defaults on desktop

Keep platform availability in the pinned-target policy so regular web hides browser shortcuts without changing Electron defaults.

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-07-16 12:36:20 +00:00
Mohamed Boudra
47532952f3 Catch broken desktop packages before release (#2114)
* test(desktop): exercise packaged app startup

The previous smoke exited before IPC, window creation, and preload execution, so a broken packaged preload could still pass. Launch the normal app and observe its renderer bridge and managed daemon through CDP instead.

* test(desktop): preserve Linux sandbox in smoke

Configure the unpacked artifact's chrome-sandbox helper with the permissions Electron requires, while keeping sandboxing enabled so preload failures remain observable. Bound startup probes and include process logs in daemon timeout failures.

* ci(desktop): keep packaged smoke on releases

Remove the temporary pull-request artifact build after it validated the Linux launch path. Keep the real packaged smoke in the native desktop release matrix.

* ci(desktop): gate packaged smoke by changes

Run the Linux real-artifact smoke in pull-request CI when packages/desktop changes, while leaving the regular desktop unit-test matrix unconditional.
2026-07-16 12:04:45 +02:00
paseo-ai[bot]
d7ca1b5a03 fix: update lockfile signatures and Nix hash [skip ci] 2026-07-16 07:55:15 +00:00
Mohamed Boudra
42e101c81e chore(release): cut 0.1.109 2026-07-16 09:46:41 +02:00
Mohamed Boudra
75d784534f docs: add 0.1.109 changelog 2026-07-16 09:43:10 +02:00
Christoph Leiter
64c819efeb fix(desktop): stop sandboxed preload from requiring a local module (#2111)
* fix(desktop): stop sandboxed preload from requiring a local module

The preload imported PASEO_BROWSER_PROFILE_PARTITION from the local module
./features/browser-profile.js. The preload runs in Electron's sandbox and is
tsc-compiled (not bundled), so that import emits require("./features/
browser-profile.js") at the top of preload.js. A sandboxed preload cannot
require a local module, so the require throws before
contextBridge.exposeInMainWorld runs. window.paseoDesktop is then undefined,
the app no longer detects itself as the desktop host, and it never starts the
built-in daemon (users had to run `paseo start` by hand).

The import was added in 0.1.108; 0.1.107 is clean, which matches the reports
that downgrading fixes it. See #2103.

Inline the one value the preload needs (the partition string) so it no longer
loads any local module, and keep browser-profile.ts as the source of truth for
the main process. Add preload-sandbox.test.ts, which parses preload.ts with the
TypeScript compiler API and asserts the only runtime module load is "electron"
(covering value imports, side-effect imports, re-exports, require, dynamic
import, and import-equals; type-only imports are ignored), plus a drift check
that the inlined literal matches the canonical constant.

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

* test(desktop): clearer preload drift-check failure message

Assert the inlined PASEO_BROWSER_PROFILE_PARTITION is present before comparing its value, so a missing/renamed constant reports "not found" instead of a misleading value-drift mismatch (Greptile feedback on #2111).

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 09:39:51 +02:00
paseo-ai[bot]
8554b94cdb fix: update lockfile signatures and Nix hash [skip ci] 2026-07-15 22:24:07 +00:00
Mohamed Boudra
75ea0d4534 chore(release): cut 0.1.108 2026-07-16 00:15:57 +02:00
Mohamed Boudra
d791a0aa91 docs: add 0.1.108 changelog 2026-07-16 00:13:46 +02:00
Mohamed Boudra
943d03ad99 fix(projects): separate registration from workspace setup
Adding or cloning a project now registers it and opens workspace setup
instead of creating a workspace implicitly. Keep Add Project independently
mounted from Search so closing one cannot control the other.
2026-07-15 23:13:55 +02:00
paseo-ai[bot]
38cfe109c9 fix: update lockfile signatures and Nix hash [skip ci] 2026-07-15 19:26:41 +00:00
Mohamed Boudra
5ef1b9dbb1 fix(website): include test dependency in deploy install 2026-07-15 21:19:40 +02:00
Mohamed Boudra
dfe3330ef8 Add a keyboard-driven project setup flow (#2097)
* feat(projects): add keyboard-driven project setup

* test(app): update add project loading assertion

* fix(projects): harden GitHub project setup
2026-07-15 21:16:10 +02:00
Mohamed Boudra
90e0a0e353 Find and open workspaces from the command center (#2096)
* feat(app): add workspaces to command center

* fix(app): scale down command center typography

* fix(app): avoid workspace theme subscription

* fix(app): theme command center bottom sheet

* fix(app): tighten command center workspace results
2026-07-15 20:40:24 +02:00
Mohamed Boudra
37bde90d9f refactor(app): unify assistant fork boundary resolution (#2091) 2026-07-16 00:54:04 +08:00
Christoph Leiter
6804882761 Document iOS/Android local dev setup and fix the mise Android toolchain (#2092)
* Pin android-sdk to 21.0 and unify Java on 21 for mise

The `.tool-versions` `android-sdk latest` pin had resolved to the
ancient `1.0` cmdline-tools bundle, whose sdkmanager (3.6.0) predates
the `emulator` package and fails with `Failed to find package
emulator`. Pin `android-sdk 21.0`, point the hardcoded install paths
in `.mise.toml` at it, and add `cmdline-tools/21.0/bin` to PATH so
sdkmanager and avdmanager resolve.

Also unify the Java version on 21: `.tool-versions` already pinned 21,
but `.mise.toml` overrode it to 17. A local `npm run android` Gradle
build succeeds on 21.

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

* Document iOS simulator and Android emulator local dev setup

Add local-dev docs for running the app against a worktree daemon:

- development.md: prerequisites for the ios-simulator preview service
  (Xcode, an iOS runtime, automatic CocoaPods install), the
  xcode-select/simctl troubleshooting fix, and a "Running the iOS app
  on a local simulator" section covering `npm run ios` and pointing
  the app at a worktree daemon via EXPO_PUBLIC_LOCAL_DAEMON.
- android.md: a "Prerequisites (local dev)" section (mise java 21 +
  android-sdk 21.0, sdkmanager components, AVD creation) and a
  "Running on an emulator against a worktree daemon" section covering
  REACT_NATIVE_PACKAGER_HOSTNAME and EXPO_PUBLIC_LOCAL_DAEMON=10.0.2.2
  for reaching the host daemon.

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

* Give full Intel x86_64 emulator setup commands in Android docs

Greptile flagged that the arm64-v8a -> x86_64 substitution for Intel
Macs was only a parenthetical. Provide a complete, copy-pasteable
x86_64 command block instead of asking readers to hand-edit the arm64
one.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 00:44:17 +08:00
1254087415
13e92f8a30 fix(server): create autonomous turns for spontaneous ACP session updates (#2058)
* fix(server): create autonomous turns for spontaneous ACP session updates

When an ACP-based provider handles background-agent completion
notifications, the resulting spontaneous session/update messages have no
active foreground turn to attach to. Mirror the Claude provider's
autonomous-turn mechanism so these updates are recorded in Paseo's
timeline.

- Start an autonomous turn when a sessionUpdate arrives with no active
  foreground turn
- Tag timeline events with the autonomous turn id
- Complete the autonomous turn after a timeout, or before a new
  foreground turn starts

* Fix ACP autonomous event scoping

---------

Co-authored-by: zab <b13022010527@gmail.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-07-15 18:42:10 +02:00
Mohamed Boudra
328361667f Keep browser sign-ins across tabs and restarts (#2089)
* feat(desktop): share persistent browser data across tabs

Keep browser sign-ins and site storage across tabs and app restarts, with a Settings action to clear the shared profile.

* fix(settings): prevent duplicate browser data clears

* fix(desktop): complete browser data cleanup

Clear legacy per-tab profiles after upgrades and localize the browser data settings in every supported language.

* fix(desktop): close browser profile cleanup races
2026-07-16 00:40:24 +08:00
Mohamed Boudra
9423b091b9 fix(app): keep tool call summaries neutral (#2090)
Individual tool outcomes remain visible in the expanded list. The aggregate header no longer treats a handled tool result as a failure of the overall work.
2026-07-15 18:34:30 +02:00
Mohamed Boudra
04e893417e Allow failed agent turns to be forked (#2063)
* fix(chat): allow failed turns to be forked

Failed turns can end in Paseo-generated timeline items without provider
message IDs. Anchor forks to canonical timeline positions while retaining
legacy boundaries for compatibility.

Place the fork summary before the new prompt and delimit it as structured
chat history.

* fix(chat): tighten fork boundary handling
2026-07-15 16:30:42 +02:00
Mohamed Boudra
f06792ae89 Recover desktop startup from stale daemon locks
Refs #1950
Refs #1904
Refs #769
2026-07-15 15:48:09 +02:00
Mohamed Boudra
7f011d16c5 Reduce composer overhead while typing (#2086)
* perf(app): reduce composer draft writes

Persist draft checkpoints every 200 ms during sustained typing while keeping the latest in-memory draft current. A trailing write stores the newest checkpoint when typing stops.

* test(app): cover draft persistence checkpoints

Keep the throttle policy deterministic at its storage boundary, including latest-value coalescing and pending-write cancellation when drafts are cleared.

* fix(app): flush pending drafts on background

Flush the latest throttled checkpoint when the app leaves the foreground and report asynchronous persistence failures. Extend the storage-boundary tests across consecutive intervals and explicit lifecycle flushing.
2026-07-15 15:40:11 +02:00
Mohamed Boudra
319d9017c2 Publish the latest Android version code (#2085)
* feat(website): publish latest Android version code

* fix(website): select latest Android APK release
2026-07-15 21:38:52 +08:00
Mohamed Boudra
4a5630bfce fix(app): stop composer height flicker
Native scrollbar appearance changed the textarea's usable width, feeding alternating measurements back into the composer height mirror. Reserve the gutter so wrapping and height remain stable.
2026-07-15 12:05:09 +02:00
Mohamed Boudra
5ddd5f3726 perf(app): keep desktop sidebar ready to reopen
Retain the hidden sidebar tree so common toggles preserve local UI state while existing activity gates pause expensive hidden work.
2026-07-15 12:05:08 +02:00
Mohamed Boudra
3673846433 fix(app): keep single tool calls in overview list 2026-07-15 12:05:08 +02:00
Mohamed Boudra
abfe955867 Show friendly native subagent names and hide finished work (#2073)
* feat(subagents): archive finished children and show native names

Keep native provider sessions intact while letting users clear completed children from the track. Persist dismissed provider child IDs so history replay does not restore them.

* fix(subagents): cover persistence and naming edge cases

* fix(subagents): preserve active descendants and native names

* fix(subagents): retain dismissed timelines and trim paths

* fix(subagents): retain history across hydration

* test(subagents): expect normalized native descriptions

* fix(subagents): separate hidden rows from retained history

* fix(subagents): preserve restored hidden tabs safely

* fix(subagents): integrate archive with run state

* test(codex): expect normalized child name

* fix(subagents): retain dismissed tabs on reload

* fix(subagents): hide finished native agents locally

Keep the header action presentation-only so it does not mutate agent lifecycle or expand the runtime protocol.

* fix(subagents): humanize native agent names

* fix(subagents): preserve names in Codex history

* fix(subagents): retain hidden rows across reloads
2026-07-15 11:58:31 +02:00
Mohamed Boudra
38e4d9ad5d feat(app): polish sidebar and tool call summaries 2026-07-15 11:24:05 +02:00
Mohamed Boudra
77f6069ec1 Keep desktop sidebar controls stable across panel changes (#2078)
* fix(app): stabilize desktop sidebar controls

Keep the left-window-controls toggle mounted across sidebar state changes so its position and tooltip state remain stable. Preserve browser placement and show an explicit Explorer close control only where native right-side controls do not provide one.

* fix(app): suppress inactive sidebar chrome
2026-07-15 15:30:57 +08:00
Mohamed Boudra
9c40cb7637 Render provider images from paths with spaces (#2074)
* fix(server): render image paths with spaces

Raw spaces in local Markdown image destinations made the renderer treat provider images as text. Emit encoded file URIs for spaced paths so the client parses and resolves them as images.

* fix(server): encode all absolute image paths

Provider image paths without spaces could still contain URI-significant characters such as fragment or query markers. Route every absolute local path through the encoded file URI boundary.

* fix(server): encode network image paths

Windows network-share paths bypassed the absolute local path handling. Normalize UNC and extended UNC paths before encoding them as file URIs.

* fix(app): preserve network image paths

Host-based file URIs were decoded as relative paths. Restore the UNC prefix so provider images on network shares resolve through the file RPC correctly.

* fix(server): preserve double-slash POSIX image paths

Classify only backslash-prefixed paths as Windows network paths so POSIX double-leading-slash paths retain their filesystem semantics.
2026-07-15 00:10:24 +02:00
Mohamed Boudra
d706c4339b Fix hidden Codex subagents and stuck parent sessions (#2068)
* fix(server): prevent hidden Codex subagents and stuck turns

Codex can announce native children only through its mirrored lifecycle stream, while rejected interrupts previously looked like successful local cancellation. Preserve those child announcements and keep manager state active until the provider acknowledges cancellation.

* fix(server): block actions after rejected cancellation

A provider can accept a turn before publishing the turn ID needed to interrupt it. Keep that interval non-cancelable, and prevent reload, replacement, or rewind from proceeding without an acknowledged cancellation.

* fix(server): surface rejected agent cancellations

Replacement prompts and Stop requests could appear accepted after the provider kept ownership of the active turn. Complete replacement cancellation before detaching the stream, and return cancellation failures through the client response.

* fix(server): handle turn completion during cancellation

* fix: finish cancellation lifecycle handling

* fix(server): close remaining cancellation races

* fix(server): settle autonomous cancellations

* fix: honor cancellation failures at call sites

* refactor(server): centralize agent run state

* fix(server): keep pending runs isolated from stale events

* fix: surface remaining cancellation failures

* fix(server): narrow cancellation error handling
2026-07-15 00:01:58 +02:00
Mohamed Boudra
50ed0d0ab1 Fix desktop layout and window controls at half-screen width (#1983)
* fix(app): preserve desktop layout in half-screen windows

Treat native window controls as physical corner obstructions instead of deriving clearance from compact layout state. Keep 751px windows in desktop mode while preserving usable content when persisted sidebars are wide.

* fix(app): retain sidebar width across window resizes

* fix(app): yield app sidebar to narrow settings

* test(app): wait for mobile sidebar motion to settle

* fix(app): yield navigation to narrow workspace explorer

* fix(app): mark sidebar sizing helpers as worklets

* fix(app): hide app navigation when space is constrained
2026-07-14 23:01:14 +02:00
Mohamed Boudra
1b8af2e58e Refine tool call summaries and scrolling (#2069)
* feat(app): refine tool call summaries and scrolling

Keep summary groups stable from the first tool call, preserve live activity feedback, and scroll expanded groups to the latest entry. Consolidate web scrollbar styling around the native browser mechanism.

* refactor(app): reuse tool call height constant

* fix(app): preserve tool call summary state

* fix(app): refresh web tool groups on expand

* fix(app): preserve grouped tool errors
2026-07-14 21:16:29 +02:00
Mohamed Boudra
d781b68711 Polish release notes and in-app help (#2070)
* feat(ui): improve changelog and help navigation

Make release notes easier to scan and reach from the app. Also keep the homepage marquee within the layout viewport so it cannot create horizontal scrolling.

* fix(website): reject duplicate release anchors
2026-07-14 21:06:25 +02:00
Ethan Greenfeld
cd6c608c9e Fix Pi slash commands hanging after local completion (#2066)
* fix(pi): complete locally handled prompts

Slash commands the Pi CLI handles without starting a model turn previously
left the Paseo turn hanging until interrupt. Parse the optional
agentInvoked correlation from prompt acknowledgements, surface
command_output/notify output as timeline content, and complete the turn
locally when no agent turn will start. Older Pi binaries without the
correlation are detected via a getState compatibility probe.

* review: guard command output without an active turn, simplify delegates

- Drop late-arriving command_output when no turn is active instead of
  emitting an orphaned timeline item with turnId undefined
- Remove redundant 'text' in event check (type is already narrowed)
- Inline recordNoTurnNotification delegate

* refactor(pi): consolidate local prompt state

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-07-14 20:29:42 +02:00
Mohamed Boudra
1f5283f5a3 Surface cross-workspace subagents in their workspaces (#2061)
* fix(workspaces): show provider subagent activity

Provider subagent lifecycle lives outside managed agent snapshots, so workspace aggregation dropped it after the parent turn finished. Count running provider subagents against the delegation root and emit workspace updates when their status changes.

* fix(workspaces): surface cross-workspace subagents
2026-07-14 14:22:14 +02:00
OnCloud
c0b801b805 Fix shortcut capture ignoring - = ; ' keys (#2047)
* Fix shortcut capture ignoring - = ; ' keys

KEY_MAP was missing Minus/Equal/Semicolon/Quote, so
keyboardEventToComboString returned null for those physical keys and
the capture UI silently dropped them. Add the four entries with their
shifted variants.

Also suppress the Electron View > Zoom accelerators (Cmd+-/=/0) while
capturing, so those combos reach the renderer instead of zooming the
window. Driven by the existing capturingShortcut flag via a new
paseo:menu:set-capturing-shortcut IPC.

* fix: address review feedback from PR #2047

Trim the capturing useEffect so it only fires when capture is active
(removes the redundant on-mount false and the double-fire on true->false),
and reset the main-process capturingShortcut flag on renderer reload via
browser-window-created + did-finish-load so a Cmd+R mid-capture can't
leave the zoom accelerators disabled.

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-07-14 13:30:32 +02:00
Matt Cowger
9ea58aae0c fix(server): stop OpenCode daemon crash on session close, fix #2014 (#2027)
* fix(server): patch OpenCode SDK unhandled rejection on stream abort

The SDK's SSE abort handler calls reader.cancel() without handling
its rejection. When Paseo aborts the event stream during normal
session close, that promise can reject and crash the daemon with an
unhandled rejection outside any Paseo-owned code path.

Patches both copies the SDK ships (dist/gen and dist/v2/gen, the
latter being what Paseo actually imports). Extends
postinstall-patches.mjs to support patch-package running from a
non-root cwd, since @opencode-ai/sdk lives in packages/server's own
node_modules rather than the hoisted root.

* fix(server): stop assuming OpenCode's default agent is "build"

OpenCode users can rename or delete any agent, including the built-in
"build"/"plan" defaults. Paseo was injecting a hardcoded "build"
agent in several places whenever no mode was explicitly requested:

- The protocol manifest declared defaultModeId: "build" for opencode,
  which AgentManager.normalizeConfig applied to every session
  (including internal metadata-generation sessions) before any
  provider-level logic ran.
- OpenCode's own normalizeOpenCodeModeId defaulted an empty/"default"
  modeId to "build" rather than omitting the agent field.
- Unattended child creates (e.g. subagents spawned by an unattended
  parent) forced modeId: "build" to express unattendedness, even
  though that's already carried by the auto_accept feature.

Now an unset mode stays unset end-to-end: the "agent" field is
omitted from OpenCode prompt/command calls entirely, letting OpenCode
fall back to its own configured default agent instead of Paseo
guessing one that may not exist.

* fix(server): validate agent create mode on the WebSocket session path

resolveAndValidateCreateAgentMode already rejects modes unknown to a
provider's discovered mode list, but it was only wired into the MCP
create path. App-created agents (create_agent_request) skipped it
entirely, so a client's remembered mode preference — which can go
stale when a user renames or deletes OpenCode agents — sailed through
to the provider and failed mid-turn with an opaque error instead of a
clear rejection at creation time.

resolveSessionCreateAgent now calls
providerSnapshotManager.resolveCreateConfig, matching the MCP path,
so an invalid mode now throws 'Invalid mode ... Available modes: ...'
immediately on create.

* chore: address review feedback on create-agent and postinstall script

- Document the cleanup-ordering constraint in resolveSessionCreateAgent:
  mode validation runs after buildSessionConfig (cwd isn't known until
  that completes), so a thrown validation error leaves any
  worktree/workspace buildSessionConfig created for the caller to clean
  up. session.ts already handles this for the worktree path; the
  directory-only workspace path has a pre-existing gap, not introduced
  by this validation.
- Log spawn errors from patch-package in postinstall-patches.mjs
  (e.g. ENOENT if it's missing from PATH) instead of failing silently.

* fix(server): don't fabricate OpenCode modes when discovery finds none

fetchModesFromClient and mergeOpenCodeModes fell back to
DEFAULT_MODES ([build, plan]) whenever OpenCode discovery returned
nothing — e.g. for a freshly-created worktree whose OpenCode server
hasn't loaded the project config yet. That fabricated list let
create-time mode validation accept a stale 'plan' preference that the
provider then rejects at prompt time (Agent not found: 'plan').

Return an empty list instead: OpenCode users can rename or delete any
agent, so any hardcoded fallback risks validating a mode that doesn't
exist. DEFAULT_MODES is retained only for description enrichment and
sort ordering.

* fix(app): reconcile stale selected mode against discovered modes on create

The mode picker displays modeOptions[0] when the stored modeId isn't
in the discovered list (e.g. a globally-remembered 'plan' that a
workspace's OpenCode config no longer defines), but the create request
still submitted the raw stored modeId. Result: UI showed 'Build' while
the request sent 'plan', which the daemon then rejected.

Reconcile the selected mode against the discovered mode ids when
building the create config (both the workspace draft composer and the
workspace-setup dialog), so the submitted mode matches what the picker
shows — falling back to the first available mode when the stored one is
absent. Mirrors the existing resolveEffectiveModel reconciliation for
models.

* test(server): fix opencode mode tests after fallback change + rebase

- Replace 'available modes include build and plan' (which relied on the
  removed [build, plan] discovery-failure fallback) with two tests:
  one asserting discovered agents map to modes, one asserting empty
  discovery yields no fabricated modes.
- Restore messageId fields in the four provider-subagent timeline
  assertions. These were accidentally dropped during rebase conflict
  resolution; the #2013 subagent code legitimately emits messageId, so
  the expectations must include it.

* test(app): fix e2e seed helper using invalid opencode mode

createIdleAgent seeded an OpenCode agent with modeId
'bypassPermissions' — a Claude mode OpenCode never had. This worked
before only because the session create path silently coerced unknown
modes to 'build'. Now that create-time mode validation rejects modes
the provider doesn't define, use 'build' + auto_accept (OpenCode's
unattended full-access equivalent), matching the rewind-flow helper.

Fixes the e2e failures across archive-tab, command-center-host,
settings-toggle-tab-regression, workspace-agent-tab-rename,
workspace-pane-remount, workspace-navigation-regression, and
worktree-restore specs, which all seed via this helper.
2026-07-14 19:00:53 +08:00
Mohamed Boudra
f35b16b316 docs(orchestration): explain cross-provider workflows (#2062) 2026-07-14 18:56:45 +08:00
Matt Cowger
81113d1582 feat(app): add tool call detail levels (#2031)
* feat(app): compact tool call sequences

* feat(app): summarize compact tool activity

* feat(app): add tool call detail levels

* fix(app): stabilize compact tool groups

* fix(app): address compact tool review feedback
2026-07-14 18:55:06 +08:00
Liam Diprose
3b078240a8 Serve the daemon web UI from Nix packages (#1978)
* fix(nix): include daemon-web-ui in nix package

* fix(nix): verify web UI without packaging website sources

* test(nix): cover bundled web UI inputs

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-07-14 18:50:09 +08:00
Xisheng Parker Zhao
279e1aa91c Fix Codebuddy Code model discovery (#1979)
* fix(app): use global codebuddy binary for codebuddy-code ACP provider

The @tencent-ai/codebuddy-code npm package ships multiple bins
(codebuddy/cbc/cbc-prewarm), so `npx -y @tencent-ai/codebuddy-code`
fails with "could not determine executable to run" and exits
immediately. Paseo's ACP catalog probe treats npx as a long-running
process, so the initialize request never gets a response and the
probe times out after 60s, leaving the model list empty.

Switch to the user-installed codebuddy CLI (matching kiro-cli,
traecli, cursor, etc.) so the probe completes and models load.

* chore: trigger CI rerun (app-tests flaky: window-is-not-defined in strategy-web.test.tsx, unrelated to this change)

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-07-14 10:15:36 +00:00
Mohamed Boudra
b4ab0d9db6 Find .opencode files in workspace search (#2049)
* fix(search): find .opencode workspace files

Workspace autocomplete skips hidden directories unless they are explicitly allowlisted. The missing .opencode entry made provider commands and workflows invisible to file search.

* refactor(search): centralize hidden directory policy
2026-07-13 22:50:34 +02:00
Mohamed Boudra
5e2a8b6633 Make help easier to find and cloned workspaces open reliably (#2045)
* feat(app): add sidebar help and support menu

Give users a single place to run diagnostics, find shortcuts, and reach the support channels. Share one global diagnostic sheet between Settings and the sidebar so both entry points collect the same report.

* fix(app): keep cloned workspace navigation atomic

A newly merged clone flow still used the previous navigation signature and opened its draft tab separately. Route the cloned workspace and its draft target through the unified navigation command so attention selection cannot replace the intended tab.

* test(app): harden sidebar help coverage

Accept both the Discord invite URL and its canonical redirect, assert placement through real element geometry, and remove an unnecessary diagnostic-host callback.

* test(app): cover support redirect variants

Keep the help-menu E2E stable across prerelease versions and the valid redirect forms returned by Discord and GitHub.
2026-07-14 00:02:53 +08:00
Mohamed Boudra
f2ebac931c Keep CI moving through transient npm failures (#2039)
* fix(ci): retry npm installs

Transient registry and package-download failures should delay a job briefly instead of requiring a manual rerun. Apply one bounded retry policy across CI, deploy, and release workflows.

* fix(ci): preserve retries for historical Android tags

Manual Android rebuilds may check out releases from before the shared retry script existed. Keep that install step self-contained and allow the shared helper's injected runner to be asynchronous.

* fix(ci): preserve retries for desktop release refs

* refactor(ci): use shared npm retry helper everywhere
2026-07-13 15:54:32 +02:00
Mohamed Boudra
f350c716b9 Keep forked chats focused in new tabs (#2038)
* fix(app): focus explicitly opened workspace tabs

Explicit tab navigation could be overwritten by attention-aware workspace
navigation when the visible agent had just finished. Make workspace navigation
own both normal attention selection and explicit targets so callers cannot
sequence them inconsistently.

* test(app): strengthen forked tab focus assertion
2026-07-13 15:38:09 +02:00
Mohamed Boudra
fe9a486ac1 Show every Codex terminal command in agent timelines (#2037)
* fix(codex): surface silent terminal activity

Successful no-output commands use a null output field, while terminal interactions carry their stdin separately. Preserve both so the agent timeline remains auditable.

* refactor(tests): share Codex event waiter

* fix(codex): preserve bounded terminal audit details

Keep terminal stdin through late command relabeling, cap it with other timeline content, and accept nullable output from legacy live completion events.

* fix(codex): preserve repeated terminal writes
2026-07-13 15:37:28 +02:00
Mohamed Boudra
13d6ad598e Keep New Workspace prompts when switching projects or hosts (#2036)
* fix(app): preserve New Workspace prompts across target changes

The New Workspace draft was keyed by the selected host and project, so changing either target replaced the visible prompt with another draft scope. Treat the screen as one persistent draft surface and migrate the newest active legacy draft.

* test(app): express draft persistence as user actions

* fix(app): clear stale picker PR context

* fix(app): persist picker attachment ownership

* fix(app): keep workspace target context safe
2026-07-13 15:36:48 +02:00
Matt Cowger
218097b7cc feat: clone GitHub repo into a workspace (#1331)
* feat: clone GitHub repo into a workspace

Add an end-to-end "clone a GitHub repo and register it as a Paseo
workspace" flow: a new workspace.github.clone RPC, daemon handler,
client method, CLI `paseo clone` command, and a GitHub-repo mode in
the project picker modal. Gated behind the workspaceGithubClone
server capability flag.

- protocol: workspace.github.clone request/response schemas + feature flag
- server: handleWorkspaceGithubCloneRequest, normalizeCloneRepository
- client: DaemonClient.cloneGithubWorkspace
- cli: `paseo clone <repo> --dir <path> [--protocol https|ssh]`
- app: GitHub-repo mode, clone-protocol picker, error surfacing

Review fixes:
- CLI clone now checks the workspaceGithubClone capability and fails
  fast with a clear "update the host" error instead of hanging for the
  full request timeout against an older daemon.
- Replace the duplicated client-side URL-detection regex (app + CLI)
  with a shared isCompleteGitRemote() in @getpaseo/protocol/git-remote,
  backed by parseGitRemoteLocation so clients classify remotes
  identically to the daemon (fixes confusing errors for git://, ftp://,
  file:// inputs).
- Add git-remote.test.ts covering the shared classifier.

* Fix GitHub clone failure handling

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-07-13 11:15:40 +00:00
Christoph Leiter
bac302626f feat(app): give each permission mode a distinct icon (#1980)
* feat(app): give each permission mode a distinct icon

The mode selector reused shield glyphs across modes: Auto mode showed a
question mark, Always Ask a checkmark, and the same icon appeared on
several modes (ShieldCheck on both Always Ask and Plan, ShieldAlert on
both Accept File Edits and Bypass). The result read as arbitrary rather
than as a scale of autonomy.

Assign every built-in provider mode a distinct icon from one shared
vocabulary, applied consistently across Claude, Codex, Copilot, and
OpenCode:

- Shield         guarded default (every action prompts)
- ShieldCheck    classifier/auto-reviewer vets prompts
- ShieldPlus     edit tools pre-approved, others still prompt
- ShieldEllipsis plan / read-only deliberation
- ShieldOff      fully permissive, no prompts

Register the new icons in the client MODE_ICONS map. Old clients
downgrade unknown icon names to ShieldCheck through the existing
customModeIcons compat gate, so the wire contract is unchanged.

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

* feat(app): order permission modes by autonomy

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-07-13 11:45:42 +02:00
paseo-ai[bot]
d8a8ac252f fix: update lockfile signatures and Nix hash [skip ci] 2026-07-13 09:23:37 +00:00
Mohamed Boudra
b6d49da4df Support F-Droid Android builds (#1768)
* fix(android): support source-based APK builds

Co-authored-by: Anton Lazarev <22821309+antonok-edm@users.noreply.github.com>

* fix(android): address fdroid review feedback

Co-authored-by: Anton Lazarev <22821309+antonok-edm@users.noreply.github.com>

* fix(android): complete the F-Droid build profile

* fix(app): isolate the F-Droid runtime flag

---------

Co-authored-by: Anton Lazarev <22821309+antonok-edm@users.noreply.github.com>
2026-07-13 17:18:19 +08:00
Mohamed Boudra
1f7efbd185 Keep attachments visible after creating an agent (#2030)
* fix(chat): keep attachments visible after creating agents

Agent creation could receive the canonical user message before the client knew the new agent ID. Hand the local rich presentation to that message and preserve it through later timeline hydration.

* refactor(chat): clarify local message presentation naming

* fix(chat): clear pending state after message handoff
2026-07-13 11:05:07 +02:00
rafavncxs
a1e81685a1 feat(app): pin chats to the top of the sidebar (#1981)
* feat(app): pin projects to top of the sidebar

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(app): dedicated Pinned sidebar section with UX fixes

Replaces the invisible float-in-place pinning with a Codex-style Pinned
section at the top of the sidebar that hoists pinned chats and projects.

- Pin-aware keyboard shortcut numbering (badges match visual order)
- Pinned chats show their project name as subtitle (context after hoisting)
- Pinned entries ordered most-recently-pinned first
- Collapsible Pinned section (persisted)
- Double-click guard on workspace pin toggle

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(app): move Pinned section above the Workspaces header

Pinned now sits at the top of the scroll, above the "Workspaces" section
header (which moves into the scroll below it), matching the market-standard
sidebar layout. Chevron sits beside the "Pinned" label.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(app): reveal Pinned collapse chevron on hover only

Extracts PinnedSectionHeader with its own hover state; the chevron hides
until hover on web and stays visible on touch/compact.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(app): pin the active chat via keyboard shortcut and inline pin button

Adds a ⌘⇧P / Ctrl+Shift+P shortcut that pins the selected chat, mirroring
the worktree.archive action across actions/dispatcher/route-shortcut, gated
by `selected && supportsWorkspacePinning`. Also surfaces an inline pin button
on row hover (always-on for touch) alongside the kebab in the shared trailing
overlay, swapping Pin/PinOff by pinned state.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(server): unify project and workspace pin handlers

Collapses the two near-identical pin.set handlers into a shared
handlePinSetRequest that owns control flow, error handling and the
workspace-update fan-out; each caller only supplies its registry apply
step and typed response payload.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(app): drop empty pinned project shells and empty Workspaces header

Pinning every chat of a project hoisted them into Pinned but left an empty
project header (plus its new-workspace ghost row) below, duplicating the
project. Skip projects emptied by pinning while keeping genuinely empty
projects. Also hide the "Workspaces" section header when no unpinned projects
remain, so it no longer floats above nothing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(protocol): correct workspacePinning COMPAT version to v0.1.105

Feature ships in 0.1.105 per the changelog, not the placeholder v0.1.103.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(app): hide redundant chat pin control inside pinned projects

A pinned project already hoists all its chats to the top, so the per-chat pin
was a no-op that just toggled a dead icon. Suppress the inline pin button, the
kebab pin item, and the pin keyboard shortcut on chats whose project is pinned;
they still work for chats in unpinned projects.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(app): hide project pin control on empty projects

Project pin state is derived from workspace descriptors, so a project with no
workspaces would set pinnedAt on the server but emit no descriptor update — the
pin silently never surfaced and the user got no feedback. Offer the pin only
once the project has a chat, matching how chat pins are suppressed inside
already-pinned projects.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(app): drop collapsed Pinned rows from keyboard shortcut numbering

When the Pinned section is collapsed its chats and projects are hidden, but the
shortcut model kept counting them: visible unpinned rows showed off-by-N badge
numbers and pressing a number key jumped to a hidden pinned chat. Exclude the
pinned rows from numbering when Pinned is collapsed, matching what renders.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(app): hydrate pinned sidebar rows

* fix(app): keep filtered workspace controls reachable

* feat(app): scope sidebar pinning to chats, drop project pinning

Per PR review: pinning whole projects overlapped with project reordering
and mixed projects into the pinned list. Keep chat pinning only.

- Remove the project.pin.set RPC, its server handler, the projectPinnedAt
  descriptor field, and the client setProjectPinned method.
- Drop the project pin control, isProjectPinned reads, and the suppressPin
  plumbing (its only source — a pinned project — is gone).
- Fold pin-aware shortcut ordering into SidebarModelProvider (the live
  shortcut owner) and delete the orphaned use-sidebar-shortcut-model hook,
  so pinned chats and their shortcut numbers share one projection.
- Prune the now-unused project pin i18n keys across all locales.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(server): inline the pin handler into the workspace path

With project pinning gone, handlePinSetRequest had a single caller. Fold it
back into handleWorkspacePinSetRequest and drop the project/workspace
generalization.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* revert: keep main's use-sidebar-shortcut-model untouched

This hook (and its test) already live on main. The scope-down had deleted
them, but that's out of scope for this PR — the pin-aware shortcut ordering
lives in SidebarModelProvider now, so main's hook is left exactly as it was.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(protocol): align workspacePinning COMPAT version in websocket-server

Match the v0.1.105 version already corrected in messages.ts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(app): reshape pinned sidebar updates

* chore(app): remove stale pin button styles

* refactor(app): simplify pinned workspace rows

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-07-13 10:59:02 +02:00
paseo-ai[bot]
8cd83b3a91 fix: update lockfile signatures and Nix hash [skip ci] 2026-07-12 22:33:52 +00:00
Mohamed Boudra
9553f4b328 chore(release): cut 0.1.107 2026-07-13 00:27:43 +02:00
Mohamed Boudra
fc7c753382 docs: add 0.1.107 changelog 2026-07-13 00:10:29 +02:00
Mohamed Boudra
96e68fea76 chore: refresh ACP provider catalog 2026-07-13 00:09:25 +02:00
psyche
1a4d7852a8 fix(agent): allow cross-provider creation for modeless providers (#2000)
* fix: 允许无模式 Provider 创建子 Agent

* test: 补充无模式 Provider 继承场景
2026-07-12 21:47:02 +00:00
psyche
c2a1ac7c3b Stop Pi metadata tasks from cluttering session history (#1999)
* fix: 避免内部 Pi Agent 持久化会话

* test: 聚焦 Pi 会话持久化行为
2026-07-12 21:35:38 +00:00
Mohamed Boudra
4a1534cacd Show only commands in Codex shell tool calls (#2029)
* fix(codex): hide shell launchers from command summaries

Codex only unwrapped POSIX shells from /bin. Commands launched through /usr/bin/zsh therefore exposed the wrapper in the tool-call row.

* test(codex): preserve shell wrapper path coverage
2026-07-12 21:12:20 +00:00
Mohamed Boudra
a849bc6bda Merge branch 'main' of github.com:getpaseo/paseo 2026-07-12 22:30:21 +02:00
Mohamed Boudra
a788a0f843 Keep agent stream controls clickable under the scroll button (#2007)
* fix(app): keep stream controls clickable under scroll button

The full-width animated overlay won browser hit testing even outside the circular control. Keep positioning on a pass-through container and animate only the button-sized child.

* test(app): preserve scroll hit-test diagnostics

* test(app): align scroll hit-test fixture deterministically

* test(server): include OpenCode message IDs in subagent assertions
2026-07-13 04:30:02 +08:00
Mohamed Boudra
a1581e66b0 fix(chat): keep large tables responsive on iOS
Selectable table cells each installed a window-level gesture recognizer, causing every touch to fan out across the entire table. Keep prose selectable while rendering iOS table text without selection.
2026-07-12 22:15:42 +02:00
Christoph Leiter
2658132384 fix(terminal): create PTYs at the client viewport size, not 80x24 (#2023)
New terminals were always spawned by the daemon at the hardcoded 80x24
default and only resized once the client's first resize arrived. On a
slow first mount that leaves a window -- or a stuck state -- where a new
terminal renders far smaller than its pane (e.g. vim in a corner).

Seed the PTY with the client's measured viewport size at creation time
so it is born at the right size and the race window is gone.

- protocol: optional `size` on create_terminal_request. Old daemons
  ignore it and keep 80x24; old clients send nothing. Fully backward
  compatible, no capability gate.
- client: a small last-measured-size cache keyed by serverId+cwd (every
  terminal in a workspace shares the pane) with a most-recent fallback;
  written on fit, read at create.
- server: thread rows/cols through the controller, terminal manager, and
  the worker-thread boundary into the existing size-aware createTerminal.

Tests: cache logic, protocol back-compat parse, controller forwarding.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 03:26:52 +08:00
Mohamed Boudra
ec93ca866e Make adding projects discoverable in new workspaces (#2026)
* fix(app): make project setup discoverable in new workspaces

* fix(app): keep project picker actions outside options
2026-07-12 19:20:31 +00:00
Mohamed Boudra
66445adc07 Show provider subagents in the subagents track (#2013)
* Add provider-owned subagent stream contract

* OpenCode provider: detect child sessions, attach adopted children to the hosting server, synthesize external turns

- Emit internal provider_child_session_detected / provider_session_deleted stream events from the global event stream (before the no-active-turn gate), including BFS hydration of existing descendants on subscribe
- Track child sessions in a registry mapping child session id -> hosting server url; resumeSession attaches via acquireExisting() so adopted children share the parent's server generation and keep receiving live events
- Synthesize external turns for adopted (externally driven) sessions so timeline, tool calls, and permission requests stream without a Paseo-initiated prompt
- Honor launchContext.env in resumeSession (acquireDedicated parity with createSession)
- Stop folding child tool parts into the parent's sub_agent action log; the parent row stays as a pointer

* Render provider subagents in workspace timelines

* fix provider subagent timeline reliability

* fix provider subagent lifecycle edge cases

* fix provider subagent event fidelity

* Fix provider subagent history fidelity

* Fix restored provider subagent state

* Fix provider subagent visibility edges

* Restore provider subagent timelines

* Page provider subagent history

* Forward subagent questions reliably

* fix(subagents): preserve OpenCode child context

* fix(subagents): harden provider timeline routing

* fix(subagents): preserve provider page continuity

* fix(subagents): retain live child prompts

---------

Co-authored-by: Omer <639682+omercnet@users.noreply.github.com>
2026-07-13 03:13:00 +08:00
Mohamed Boudra
41d882859c Show Fork chat for every agent provider (#2022)
* fix(chat): show fork controls for every provider

Real assistant messages require stable identity so provider-agnostic chat actions can address the selected turn. Preserve provider IDs where available and generate stable adapter-owned IDs otherwise.

* test(opencode): expect assistant message identities

* test(pi): cover assistant updates without message start

* fix(providers): preserve late assistant identities

* fix(opencode): track fallback compaction identities
2026-07-13 03:01:08 +08:00
Mohamed Boudra
88397655f7 fix(acp): keep tool execution with agents by default (#2024)
Paseo should only advertise filesystem and terminal execution when a provider explicitly opts into delegating those operations to the host.
2026-07-12 20:43:40 +02:00
Mohamed Boudra
18de06c2a4 Show tooltips for sidebar footer actions (#2025)
* fix(app): label sidebar footer actions

* fix(app): translate hosts tooltip
2026-07-13 02:36:01 +08:00
Victor Araújo
a9ba0392b7 feat(acp): configure generic client capabilities
Closes #2012
2026-07-12 20:10:35 +02:00
Mohamed Boudra
cf6c014b6b Keep oversized tool output out of chat timelines (#2020)
* fix(server): bound shell tool output in timelines

Provider tool output could enter timeline persistence and live streams without a size limit. Bound canonical shell output before coalescing, storage, history hydration, and dispatch so every provider follows the same 64 KiB budget.

* refactor(server): slice oversized tool output directly

* fix(server): cover imported and failed shell output
2026-07-12 14:24:18 +00:00
Mohamed Boudra
e18cfb7639 Keep Pi chats usable after canceling extension commands (#2019)
* Fix Pi sessions stuck after interrupt

* Guard Pi retries from stale prompt failures
2026-07-12 15:33:51 +02:00
Christoph Leiter
c05e337cde fix(dev): drop unneeded cross-env from paseo.json dev scripts (#1637)
When running Paseo on the Paseo repo itself, the worktree setup step and the
paseo.json service scripts (daemon/app/desktop/ios-simulator) fail with
"cross-env: command not found" unless cross-env is installed globally. The
daemon runs paseo.json commands in a plain shell without the project's
node_modules/.bin on PATH (unlike npm run), so the bare cross-env (a local
devDependency) does not resolve.

cross-env only exists for Windows cmd compatibility, and every one of these
commands launches a unix ./scripts/*.sh, so it was not buying anything here.
Drop it and set the env vars inline. The cross-env dependency stays -- it is
still used by the npm run scripts where it is actually needed.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 11:40:39 +00:00
paseo-ai[bot]
71c66823aa fix: update lockfile signatures and Nix hash [skip ci] 2026-07-12 08:47:55 +00:00
Mohamed Boudra
a1821d5863 chore(release): cut 0.1.106 2026-07-12 10:42:56 +02:00
Mohamed Boudra
5db070a4d9 docs: promote 0.1.106 release notes 2026-07-12 10:40:19 +02:00
Matt Cowger
461be47c91 fix(server): await OpenCode event stream shutdown (#2015)
* fix(server): await OpenCode event stream shutdown

* Update opencode-agent.test.ts

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-07-12 16:20:13 +08:00
Mohamed Boudra
92d7066601 fix(app): prevent white flashes between workspace routes
Nested native stacks fell back to the navigation library's light background during cross-stack swaps. Share the active app surface color across every stack without subscribing route layouts to all theme runtime changes.
2026-07-12 00:27:40 +02:00
Mohamed Boudra
d28e174b38 ops(relay): require manual deployment 2026-07-12 00:09:15 +02:00
paseo-ai[bot]
a257a1c5f6 fix: update lockfile signatures and Nix hash [skip ci] 2026-07-11 18:59:45 +00:00
Mohamed Boudra
144f951a79 ops(relay): merge production bridge to Fly so release deploys preserve it 2026-07-11 20:55:56 +02:00
Mohamed Boudra
51fea4b7e0 docs: clarify mobile beta release note 2026-07-11 20:47:48 +02:00
Mohamed Boudra
e98ba1beb1 chore(release): cut 0.1.106-beta.1 2026-07-11 20:45:30 +02:00
Mohamed Boudra
3bbbfbc880 docs: add 0.1.106 beta release notes 2026-07-11 20:43:14 +02:00
Mohamed Boudra
f86226a196 chore: update ACP provider catalog 2026-07-11 20:42:47 +02:00
Mohamed Boudra
120842264e fix(app): do not promote archive as primary for regular Git checkouts 2026-07-11 18:58:24 +02:00
Mohamed Boudra
a017ddfc29 fix(app): hide header Git actions for non-Git workspaces 2026-07-11 18:34:26 +02:00
Mohamed Boudra
03dcda7c41 Archive any workspace from the app (#2003)
* feat(app): expose archive for every workspace

Workspace actions now archive one workspace by id. The daemon remains responsible for backing-specific cleanup, including removing a managed worktree only after its final workspace is archived.

* refactor(app): clarify archive descriptor name

* fix(app): preserve workspace archive safety

* fix(app): allow archive without upstream

* refactor(app): remove stale archive callback

* test(app): cover workspace archive ownership
2026-07-11 18:22:31 +02:00
Matt Cowger
ada0955334 fix(server): preserve Pi MCP config during injection (#1990) 2026-07-11 23:56:42 +08:00
Mohamed Boudra
ca5c786ccf Stop reconnects from reviving agents during shutdown (#1977)
* fix(server): stop reconnects reviving agents during shutdown

Shutdown previously took its agent snapshot while reconnects and in-flight registrations could still add or repersist agents. Gate ingress and registration first so closed agents cannot return during teardown.

* fix(server): drain late sessions during shutdown
2026-07-11 23:48:56 +08:00
Mohamed Boudra
5ae53c7e55 ops(relay): bridge production traffic to Fly 2026-07-11 16:49:15 +02:00
Mohamed Boudra
c800c6a3f1 Allow Codex MCP approval prompts (#2001)
* fix(codex): surface MCP approval prompts

* fix(codex): return accepted elicitation content

* fix(codex): reject unsupported MCP URL elicitations

* fix(codex): clear resolved MCP approvals

* fix(codex): decline unsupported MCP forms

* fix(codex): cancel resolved MCP prompts

* fix(codex): ignore resolved MCP approvals

* fix(codex): request MCP form elicitations
2026-07-11 22:15:17 +08:00
Mohamed Boudra
adc9b3b43c Preserve loader behavior while reducing hidden work (#1996)
* fix(app): preserve loader behavior in retained panels

Keep the running footer mounted across panel switches. Inactive loaders stop their animation work, then realign to the original wall-clock phase before they become visible.

* perf(app): remove inactive loader wrapper worklet

* perf(app): pause hidden turn elapsed timers

* perf(app): reduce loader animation work

Keep one wall-clock-aligned clock for visible loaders and detach retained
hidden loaders without unmounting them. Preserve the 950 ms sequence and
reduced-motion behavior.
2026-07-11 13:25:24 +02:00
Mohamed Boudra
5d18edd31e Verify real agent workflows through the hosted relay (#1998)
* test(relay): prove live daemon client workflow

* test(relay): reduce live acceptance variance

* test(relay): run acceptance through real codex
2026-07-11 12:00:42 +02:00
Mohamed Boudra
6c764f211a fix(app): stop updates after chat teardown (#1997)
Plain chat views started the history virtualizer even without virtualized rows. Its delayed scroll callback could outlive the mounted view and make the app test job fail during environment teardown.
2026-07-11 09:12:28 +00:00
Mohamed Boudra
28a27b02d3 Prevent Android chats from freezing or going blank (#1989)
* fix(app): stabilize streamed chat rendering

Use deterministic Markdown AST keys and preserve unique row identities when an assistant message resumes after a tool. Keep native gesture and plan-card children correctly keyed to avoid unsupported Fabric reconciliation paths.

* fix(app): stabilize retained native panels

Keep retained workspace roots in a stable native order and make active
panels present in the selection commit. Hidden panels now stop expensive
subscriptions and animations without losing mounted state.

* fix(app): harden retained panel activity
2026-07-11 15:39:14 +08:00
paseo-ai[bot]
860fcb2e35 fix: update lockfile signatures and Nix hash [skip ci] 2026-07-10 11:03:47 +00:00
Mohamed Boudra
4c72bf0209 chore(release): cut 0.1.105 2026-07-10 12:58:31 +02:00
Mohamed Boudra
8c639fd796 fix(app): stabilize project picker E2E selectors
Project picker tests had depended on a placeholder changed for fuzzy search. Use a dedicated input test ID so product copy no longer breaks the selectors.
2026-07-10 12:39:40 +02:00
Mohamed Boudra
aa656772ef docs: add 0.1.105 changelog 2026-07-10 12:24:58 +02:00
Mohamed Boudra
4dd783c36a docs: date Pi stats compatibility shim 2026-07-10 12:24:45 +02:00
Mohamed Boudra
e6a316456d chore: refresh ACP provider catalog 2026-07-10 12:24:35 +02:00
Mohamed Boudra
3c2339f841 fix(app): clarify fuzzy project search 2026-07-10 12:01:50 +02:00
Mohamed Boudra
61a9117a6b Restore fuzzy project folder search and desktop browsing (#1968)
* fix(projects): restore folder search and browsing

* fix(projects): preserve fuzzy search edge cases

* test(projects): move picker cleanup into fixture

* fix(projects): preserve absolute path descendants

* test(server): compare canonical search paths

* fix(projects): anchor root path suggestions

* fix(projects): anchor Windows root suggestions

* fix(projects): anchor directory search paths

* test(server): canonicalize directory search root

* fix(app): anchor rooted basename suggestions

* refactor(search): unify directory suggestions

Use one parameterized daemon search engine for project paths and workspace entries. Keep local recommendations as a small client overlay and remove unnecessary response metadata.

* fix(search): preserve filesystem root semantics

Accept absolute queries through configured root aliases while keeping traversal and output canonical. Keep blank suffix browsing matcher-independent and avoid metadata-keyed listing caches where Windows cannot invalidate them reliably.
2026-07-10 17:52:55 +08:00
Mohamed Boudra
1f0285c06b fix(app): restore mobile left sidebar swipe (#1976)
Keep the shared sidebar model outside the root gesture detector so Gesture Handler attaches to the native surface on Android/Fabric.
2026-07-10 11:34:47 +02:00
Mohamed Boudra
a689ccb0af Fix Codex status and streaming during sub-agent work (#1967)
* fix(codex): keep parent turns active during sub-agent work

Codex multi-agent activity is thread-scoped. Treating child events as root
activity could end the foreground turn early and splice child output into
parent messages.

Route legacy and current activity shapes through stable child identities and
preserve message boundaries across live and resumed turns.

* fix(codex): preserve sub-agent details across stream chunks

Keep empty activity paths explicit and normalize the synthetic assistant
boundary for every chunk in the active message.

* fix(codex): route legacy child events by thread

* fix(codex): stabilize sub-agent activity lifecycles

* fix(codex): retain child tool activity and terminal state
2026-07-10 09:41:32 +02:00
Mohamed Boudra
cac71ee8f5 Hide keyboard shortcut badges in workspace menu on native (#1964)
* Hide keyboard shortcut badges in workspace menu on native

The archive item's shortcut badge (e.g. Ctrl+Shift+⌫) rendered in the
workspace kebab/context menu on mobile, where there's no physical
keyboard to invoke it. Gate the trailing badge behind !isNative in both
kebab menu variants.

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

* fix(app): hide native shortcuts in every workspace menu

Workspace menus were duplicated across project and status views, so the status path missed the native gate. Centralize the menu so every sidebar view shares the same shortcut policy.

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 00:42:36 +02:00
Mohamed Boudra
a743a22496 Keep large sidebars responsive (#1966)
* perf(app): keep large sidebars responsive

Sidebar rows rescanned every agent for each session update, multiplying work with both workspace and agent count. Build workspace activity once and share the derived sidebar model so updates scale with the underlying collections.

* test(app): use valid agent fixture for workspace restore

* refactor(app): keep sidebar model surface focused
2026-07-10 06:12:56 +08:00
Mohamed Boudra
b2c6476817 Choose non-fast variants for ACP models (#1952)
* fix(providers): expose ACP fast-mode selection

Provider wrappers now forward draft feature lookups to inner ACP clients, and ACP adapters can add initialize metadata for model parameterization. Fast-mode config options flow through the existing provider feature path.

* test(providers): cover wrapped ACP command lookup

* fix(providers): submit Cursor non-fast defaults

* fix(app): preserve parameterized model preferences

* fix(providers): preserve Cursor ACP feature params

* fix(providers): keep Cursor legacy model fallbacks

* fix(providers): preserve Cursor model parameter switches

* refactor(providers): simplify ACP model feature negotiation
2026-07-10 05:16:17 +08:00
Mohamed Boudra
6af1379357 Stop mobile sidebars getting stuck out of sync (#1953)
* fix(app): keep mobile sidebars synced with store

Gesture-owned skip flags could swallow the next store sync after a no-op or overlapping gesture, leaving React state and Reanimated shared values split. Always reconcile both mobile sidebars from the panel store after gesture animations.

* test(app): keep sidebar sync coverage pure

* fix(app): keep sidebar close transitions idempotent

* fix(app): keep mobile sidebars stable through interruptions

Independent React and worklet state let stale gesture or animation callbacks
overwrite newer panel commands. Use one revisioned target and one transient
motion runtime so outdated callbacks cannot restore an earlier panel.
2026-07-09 23:08:56 +02:00
Octopus
15d3091104 Update MiniMax structured generation fallback (#1955)
Prefer MiniMax M3 for metadata generation and align the related docs/tests with the newer provider model.

Co-authored-by: octo-patch <266937838+octo-patch@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-09 18:26:12 +00:00
Mohamed Boudra
ebaecace2b Keep Pi text-only sessions from bricking on images (#1960)
* fix(pi): avoid raw images for text-only models

Pi text-only models reject image content, and Pi persists the rejected raw image in session history. Materialize those image prompts to local files and send path hints while preserving raw images for vision-capable models.

* fix(pi): require explicit vision support

Default unknown Pi model capabilities to the text-hint path so raw image forwarding only happens for models that declare image input. Also keep materialized image files in a private per-user temp directory with restrictive file modes.

* fix(pi): harden materialized image temp dirs

* test(server): accept per-user image attachment dirs

* fix(server): use private temp dirs for materialized images

* fix(server): refresh missing image temp cache

* test(server): clean image temp dirs
2026-07-10 01:34:34 +08:00
Matt Cowger
d96d599466 Show reasoning output automatically when enabled (#1943)
* feat(app): add auto-expand reasoning setting

* chore: add cross-env mise tool

* fix(app): stabilize web export and settings detail placement

* Revert "chore: add cross-env mise tool"

This reverts commit f56a367e0c.

* perf(app): narrow settings reads in agent stream

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-07-08 21:39:03 +00:00
Mohamed Boudra
29a3261dd6 Restore compact Changes diff controls (#1945)
* feat(app): restore compact Changes diff controls

Keep the folder tree behind a view toggle while preserving the flat file list as the default.

Move secondary diff options into the overflow menu so the Changes toolbar stays compact.

* fix(app): mirror whitespace diff option copy

* fix(app): polish diff controls review follow-up
2026-07-08 21:20:51 +00:00
Slava Goltser
c7406a415d fix(app): handle CancelledError from cancelled provider usage queries (#1885)
Move canFetch guard before first await so disabled refresh bails out synchronously instead of entering async path. Add .catch() swallow for fire-and-forget call to prevent unhandled promise rejections when react-query cancels pending queries during rapid tooltip open/close cycles.
2026-07-09 05:00:57 +08:00
Slava Goltser
202c417347 fix(pi): add get_session_stats fallback from get_state for old OMP binaries (#1886)
* fix(pi): add get_session_stats fallback from get_state for old OMP binaries

Older Oh My Pi binaries don't support the get_session_stats RPC command,
leaving session usage stats blank in Paseo. This adds a compat layer in
cli-runtime.ts where getSessionStats() tries get_session_stats first, then
falls back to extracting context window usage from get_state.

The fallback provides contextUsage.tokens and contextWindow even when the
binary lacks full token/cost reporting — enough to keep the context meter
working. Token counts and cost still require the newer RPC.

Added three tests:
- Fallback triggers when get_session_stats is unsupported (throws)
- No fallback when get_session_stats returns valid data
- Returns empty object when both commands fail

* fix(pi): use nullish check instead of truthy for cost field in getSessionStats
2026-07-09 05:00:17 +08:00
Christoph Leiter
3259a69466 feat(app): folder tree in the Changes view (#1918)
Group changed files in the Changes view into a collapsible directory
tree instead of a flat list, on every form factor — consistent with the
Files explorer, which already renders a directory tree on mobile.
Folders are collapsible and expanded by default, single-child directory
chains are compressed into one row (packages/app/src), and folder rows
show aggregate +/- stats.

Client-only, built entirely from the existing ParsedDiffFile paths:

- diff-tree.ts: pure build/compress/flatten of the directory hierarchy,
  keyed by full uncompressed dir path so collapse state survives
  compression changes as the diff mutates.
- diff-flat-items.ts: pure buildDiffFlatItems + sumHeightsBefore. The
  function retains a flat mode (showTree=false) as a tested seam for a
  possible future flat/tree toggle; sticky header indices are
  file-headers-only, derived from the post-collapse list.
- panel-store: diffCollapsedFoldersByWorkspace slice (collapsed-set, so
  folders default to expanded), persisted via version 12. The set is
  reconciled against the current diff so folders that leave and reappear
  default to expanded and the stored set stays bounded.

Presentation primitives (chevron, indent guides, indent constant) are
extracted into components/tree-primitives and shared with the Files
explorer, which drops its private copies (and a banned useUnistyles
call site). The two trees now render identical rows; only the
tree-building stays separate (server listings vs. diff paths).

The toolbar's expand-all stays file-diffs-only (renamed
allFileDiffsExpanded); a separate control collapses/expands all folders.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 03:55:30 +08:00
Mohamed Boudra
cab0e4b3f8 Show a recovery screen for app render errors (#1924)
* fix(app): show recovery screen for render failures

Render a retryable fallback with useful error details when the routed app tree throws during render instead of leaving the app blank.

* test(app): cover root error details without mounting

Move caught-value formatting into a pure module, cover AggregateError fields without duplicate output, and remove the component-mounted test.

* fix(app): keep empty error details caught

* fix(app): preserve nullable aggregate errors

* fix(app): guard malformed error details

* fix(app): handle recursive error causes

* fix(app): guard error detail formatting failures

* fix(app): localize root error fallback

* fix(app): keep root error fallback on existing i18n
2026-07-09 00:06:29 +08:00
Mohamed Boudra
6ca1f4dac5 docs: browser is desktop-only, drop phone mention 2026-07-08 13:59:27 +02:00
Mohamed Boudra
6f3ab98986 docs: restructure browser IA, add 'when to use' page 2026-07-08 13:56:57 +02:00
Mohamed Boudra
251d88a37a Stop Android audio interruptions crashing voice mode (#1941)
* fix(android): handle denied voice audio focus

Android can deny voice-mode audio focus while another system audio owner, such as an incoming call, is active. Treat that as an interruption so resume does not crash the app and JS stops voice mode coherently.

* fix(voice): keep interruption state consistent

Make native interruption handling payload-aware so iOS resume events do not stop the shared JS runtime, avoid ending voice mode for duck-only Android focus changes, and roll host voice mode back if capture fails after startup enabled it.

* fix(voice): avoid duplicate interruption handling

Keep exactly one Android audio-focus request active at a time and leave JS capture state untouched for non-terminal interruption events.

* fix(android): abandon blocked audio focus requests

When Android denies or blocks voice audio focus, abandon the pending focus request so delayed focus gain cannot arrive after voice mode has already stopped.

* fix(voice): handle interrupted dictation capture

Stop Android resume from replaying after recording restart fails, and propagate native blocked interruptions into dictation so users do not remain in a stale recording state.
2026-07-08 19:45:02 +08:00
Mohamed Boudra
c72bcb49e5 docs: restructure schedules IA (app-first), add Orchestration category 2026-07-08 13:43:59 +02:00
paseo-ai[bot]
1a03171f0c fix: update lockfile signatures and Nix hash [skip ci] 2026-07-08 10:52:05 +00:00
Mohamed Boudra
f3fdeab131 chore(release): cut 0.1.104 2026-07-08 12:47:07 +02:00
Mohamed Boudra
fdc6a4a57d docs: changelog for v0.1.104 2026-07-08 12:44:08 +02:00
Mohamed Boudra
ed67e6e0f1 fix(app): preserve renamed host labels (#1940)
Daemon-advertised hostnames still name new/default hosts, but connection refreshes and re-pairing no longer overwrite labels that users already changed.
2026-07-08 10:20:06 +00:00
Mohamed Boudra
5ec7ef9771 perf(agent-tools): shrink MCP tool catalog context cost (#1939)
Drop advertised MCP output schemas (no supported harness forwards them to the model) and remove the redundant wait_for_agent tool. Core agent tool catalog drops ~20.5k -> ~5.6k estimated tokens; ~24.9k -> ~9k with browser tools. Runtime structuredContent is preserved via server-side text serialization. Also corrects the agent-wait timeout guidance for blocking callers.
2026-07-08 11:39:39 +02:00
Mohamed Boudra
4fb70668f2 Manage the built-in daemon from one place in settings (#1938)
* feat(settings): consolidate daemon management into the host page

The app-level Daemon section and the localhost host page rendered the
same daemon card in two places. The host page is now the single home:
the Host sidebar group leads with Overview, and a desktop-only "Enable
built-in daemon" option in the host switcher recovers a removed
localhost connection. Old settings/daemon deep links redirect
(COMPAT-tagged).

* fix(settings): hide daemon recovery option when the server id probe fails

Review feedback: !query.isLoading treated an errored IPC probe as
resolved, so the recovery affordance could show without knowing the
local server id. Replace the resolved-boolean hook with a single
discriminated state hook (loading | error | resolved) — errors hide the
affordance and fall back to the settings root in the redirect. Also
collapses the two query subscriptions per caller into one.

* fix(settings): keep daemon recovery reachable when no hosts remain

Review feedback: removing localhost as the only host takes the settings
sidebar down its no-host branch, which never mounts the host picker —
so the "Enable built-in daemon" option was unreachable in the most
common recovery case. The sidebar now owns the single hook call, passes
it to the picker, and renders the same action as a sidebar row next to
"Add host" when no hosts exist.

* test(e2e): update settings host header to Overview

Test drift: the host section's sidebar/header label was intentionally
renamed to "Overview"; the spec still asserted the old "Host" title.
Also assert the retired app-level Daemon row stays absent.
2026-07-08 10:20:30 +02:00
paseo-ai[bot]
06c3161008 fix: update lockfile signatures and Nix hash [skip ci] 2026-07-07 21:06:43 +00:00
Mohamed Boudra
a85139c3f3 chore(release): cut 0.1.104-beta.5 2026-07-07 23:01:34 +02:00
Mohamed Boudra
be756c9004 docs: changelog for v0.1.104-beta.5 2026-07-07 22:58:17 +02:00
Mohamed Boudra
8d0b3e69e8 chore(providers): update ACP catalog pins 2026-07-07 22:57:49 +02:00
Mohamed Boudra
d9b65e269f Scheduled runs each get their own workspace in the sidebar (#1934)
* feat(schedules): per-run workspaces, isolation and archive controls

Scheduled agents never appeared in the sidebar: each schedule reused one
stamped workspace and archived the run's agent on completion. Now every run
mints its own workspace — the entity the sidebar shows — with per-schedule
controls for isolation (local or worktree) and whether to archive that
workspace when the run finishes.

The schedule form is rebuilt on a non-React form model (open/commands/close)
to end the flicker, edit-into-create contamination, and multi-host hydration
bugs that came from effect-choreographed shared state. That lands the reusable
foundations it needed: a form kit over a single control-geometry owner, a
data-access taxonomy (replica/snapshot/fetch) with real load-state semantics,
and lint/boundary guardrails so new screens inherit the paved road.

Also fixes desktop combobox popovers truncating options when the trigger is
narrower than the content. New schedule protocol fields are optional on both
create and update, so old and new clients/daemons stay compatible.

* test(app): complete mock themes for control-geometry tokens

switch and terminal-profile-edit-modal now style through
createControlGeometry, which reads theme.borderWidth[1], theme.opacity[50],
and theme.colors.borderAccent. The hand-rolled mock themes in these two
suites lacked those tokens, so the eager StyleSheet.create mock threw at
import (collection error), not on any assertion. Add the missing tokens.

* fix(schedules): address review findings on run cleanup, edit isolation, load error, sheet dismiss, and preference hydration

- Server: archive the run's workspace even when agent creation fails, so a
  misconfigured provider no longer orphans a sidebar workspace/worktree.
- Edit form: keep a stored worktree isolation while worktree eligibility is
  still resolving, instead of silently downgrading a saved schedule to local.
- Schedules screen: show the load error/retry UI when every host fails, rather
  than spinning forever behind the loading branch.
- Form sheet: fire the parent onClose on native gesture/backdrop dismiss so the
  sheet closes instead of re-opening.
- Form model: apply late-loading saved preferences to untouched create-mode
  fields (never to edited schedules or user-modified fields).

* fix(schedules): review batch — reconnect resubscribe, worktree prune, Android dismiss, cadence and provider edit safety

- Push-router: re-send terminal and checkout-diff subscriptions after a
  reconnect; the old per-hook effect resubscribed on isConnected and the
  extracted router did not, so daemon restarts silently stopped terminals_changed
  pushes (root cause of the terminal-activity e2e failures).
- Server: pass the run's source repo root when archiving worktree runs so the
  worktree is unregistered (git worktree remove/prune), not just deleted; also
  archive the workspace when agent creation fails even with archive-off (an
  agentless workspace has nothing to inspect).
- Sheet: RN Modal onDismiss is iOS-only — notify dismiss once-per-close on the
  Android native path too; replace the JSDOM component test with a real
  Playwright dismiss spec and inline the trivial dismiss decision.
- Form model: editing an interval schedule no longer rewrites its cadence to
  cron unless the cadence UI is touched (90-minute intervals have no cron
  equivalent); switching projects clears stale provider/model state and gates
  submit until the new host's snapshot resolves.

* chore(lint): drop custom lint wrapper and boundary script, plain oxlint

The import bans stay in .oxlintrc.json (oxlint-native no-restricted-imports).
The receiver-sensitive checks the custom script enforced are not worth a
parallel lint pipeline; if they come back it will be as oxlint rules.

* fix(schedules): crash-safe run cleanup, capability-gated run options, resilient mutations

- Persist the run's workspace/agent ids on the running-run record so a daemon
  crash mid-run can be recovered: restart archives the interrupted run's
  workspace under the same policy as normal cleanup (agentless always archives,
  otherwise archiveOnFinish is respected).
- Hide Archive on finish (and omit archiveOnFinish/isolation from payloads) on
  hosts without workspaceMultiplicity — an old daemon ignores the fields, so
  offering the switch there would lie.
- Optimistic pause/resume/delete no longer throw when the schedules cache holds
  a still-connecting entry alongside loaded data.
- Mode field is gated on provider mode options, not a selected model, so
  model-less providers can pick their advertised modes.
2026-07-07 22:13:42 +02:00
Mohamed Boudra
d3e1fe48f6 Scheduled and loop agents get workspaces; schedules UI speaks the user's timezone (#1909)
* fix(app): schedules UI speaks the user's timezone

* refactor(server): require explicit agent placement and bind create-agent command

* fix(server): scheduled agents get a workspace and show in the sidebar

* fix(server): loop agents get a workspace and show in the sidebar

* fix(server): preserve scheduled and loop run semantics through the create command

* fix(server): serialize all schedule record writes through the per-schedule queue

* fix(server): tighten create-agent dependency contracts

* fix(server): handle schedule restamps and loop cancellations

* fix(server): preserve scheduled and loop create semantics

* refactor(server): schedule store owns atomic mutations

* fix(server): keep schedule stamp reuse deterministic

* fix(server): handle loop workspace stamp rejection

* refactor(server): schedule store owns typed identity upsert

* fix(server): keep schedule workspace stamps server-owned

* fix(server): preserve scheduled run semantics

* refactor: required nullable workspaceId replaces placement union; pure cadence policy

* fix(server): tighten scheduled and loop agent edges

* fix(server): fail canceled scheduled runs

* fix(server): close schedule and loop cwd edges

* fix(server): refresh schedule workspace stamps

* fix(server): preserve schedule timezone on replace

* fix(server): close loop stop and cadence reset edges
2026-07-07 17:13:28 +08:00
Mohamed Boudra
783f43c580 Mark bundled-CLI daemons as desktop managed (#1919) 2026-07-06 18:08:18 +02:00
Mohamed Boudra
503a5a9e1e Fix desktop-managed daemon restart from settings (#1915)
* fix(settings): restart managed desktop daemon fully

* fix(settings): respect paused desktop daemon management

* test(settings): cover restart routing through public helper

* fix(settings): wait for daemon restart disconnect

* fix(settings): handle restart review edge cases
2026-07-06 17:50:49 +02:00
Mohamed Boudra
f21c997b17 Include desktop app logs in diagnostics (#1914)
* fix(diagnostics): include desktop app logs

Settings diagnostics now collect the Electron main-process log separately from daemon.log so desktop startup and environment-probe failures are included in app reports.

* refactor(desktop): share log tail reader

* fix(diagnostics): surface desktop app log read errors

* refactor(diagnostics): tighten desktop report API
2026-07-06 17:50:45 +02:00
Mohamed Boudra
7c1da0077b Fix desktop PATH resolution when shell startup fails (#1916)
* fix(desktop): retry login shell env resolution

Finder-launched desktop sessions can inherit launchd's minimal PATH if the login shell probe times out or never prints its marker. Retry once with a non-interactive login shell so profile PATH still has a chance to apply.

* fix(desktop): cap login shell probe retry budget

* fix(desktop): repair csh login env retry

* test(desktop): pin login shell env retry platform

* refactor(desktop): use object params in shell env helpers
2026-07-06 17:50:42 +02:00
yz
d318629121 Fix default bundled web UI path in packaged desktop CLI (#1899)
* Fix packaged web UI dist dir resolution

* Fix bundled web UI path typecheck

* Fix packaged web UI path matcher

* Remove duplicate packaged web UI clause

* Merge packaged web UI path branch

* Use bundled web UI artifact existence

* Inline bundled web UI existence checks

* Restore original web UI fallback branch

* Reuse app-dist path in web UI resolver

* Format web UI resolver tests and config

* Tidy web UI resolver follow-up

* fix(server): tighten bundled web UI path resolution

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-07-06 16:24:02 +02:00
Mohamed Boudra
08a0d0c28d Fix worktree setup scripts losing PATH (#1908)
* fix(worktree): preserve PATH for lifecycle scripts

Lifecycle command strings were running through login shells, which could rewrite PATH before setup commands saw the daemon environment. Use the shared stable script-shell helper without login startup files, and route related project-authored command strings through it.

* fix(worktree): ignore Bash startup env hook

Project command strings should not source shell startup hooks before they see Paseo's supplied environment. Strip BASH_ENV from lifecycle, loop verify, and ACP string-command shells and pin the behavior in setup tests.

* fix(worktree): resolve Bash through PATH

* fix(worktree): preserve Windows cmd command strings

* test(acp): expect Windows cmd terminal strings
2026-07-06 20:34:42 +08:00
Xisheng Parker Zhao
61df450366 fix(server): surface TRAE CLI slash commands and skills over ACP (#1896)
traecli publishes its slash commands and skills asynchronously via the standard
ACP available_commands_update notification ~400ms after session/new resolves.
Because the catalog entry runs through the plain GenericACPAgentClient
(waitForInitialCommands defaults to false), listCommands() resolves before that
first batch arrives and the Paseo UI shows an empty slash menu — intermittently,
depending on whether the menu is opened inside the ~400ms race window.

Add a thin TraeACPAgentClient that sets waitForInitialCommands: true (10s
timeout) and wire it into the extends:"acp" derived-provider branch, mirroring
CursorACPAgentClient exactly. Cursor has the identical async-commands behavior;
this reuses that adopted pattern rather than introducing anything provider-
specific. Unlike Kiro (#1792), traecli uses the standard available_commands_update
update type, so no extensionCommandsParser is needed.
2026-07-06 20:30:22 +08:00
paseo-ai[bot]
5d25ed6bf8 fix: update lockfile signatures and Nix hash [skip ci] 2026-07-06 08:03:12 +00:00
Mohamed Boudra
391cf75ece chore(release): cut 0.1.104-beta.4 2026-07-06 09:58:20 +02:00
Mohamed Boudra
7ee2d6fd7e docs: changelog for v0.1.104-beta.4 2026-07-06 09:56:20 +02:00
Mohamed Boudra
b1d11fccd5 Hide browser tools unless enabled
Browser tools are now hidden from agent tool catalogs unless daemon browser tools are enabled.
2026-07-05 19:02:59 +02:00
paseo-ai[bot]
05f8dda1e3 fix: update lockfile signatures and Nix hash [skip ci] 2026-07-05 16:11:29 +00:00
Mohamed Boudra
9968f2ba50 Speed up inbound WebSocket validation (#1895)
* perf(protocol): generate inbound ws validators

* perf(client): use generated ws validation

* docs(protocol): document generated validation

* fix(protocol): make validator generation source-only

* test(protocol): cover explicit provider model normalization

* fix(protocol): preserve inbound compat defaults

* fix(protocol): make validator import rewrite portable

* fix(protocol): harden validation safety checks

* fix(protocol): guard generated validator boundaries

* fix(protocol): normalize legacy inbound defaults

* fix(protocol): simplify generated validation safety net

* fix(protocol): keep cold typecheck source-only

* fix(protocol): encapsulate validator codegen

* fix(protocol): bootstrap source-alias typechecks

* fix(app): keep source aliases out of bundler config

* fix(protocol): harden validator codegen packaging

* fix(protocol): own zod-aot patches in generator

* fix(protocol): trim validator safety net

* fix(client): normalize provider updates before dispatch

* fix(protocol): keep validator generation out of install
2026-07-06 00:07:54 +08:00
paseo-ai[bot]
0ecf2fb8da fix: update lockfile signatures and Nix hash [skip ci] 2026-07-04 08:26:52 +00:00
Mohamed Boudra
b3eb77981a chore(release): cut 0.1.104-beta.3 2026-07-04 10:22:14 +02:00
Mohamed Boudra
3e28b9b59e chore(providers): update ACP catalog pins 2026-07-04 10:20:07 +02:00
Mohamed Boudra
42d1a2e4e3 docs(browser): document browser automation 2026-07-04 10:19:53 +02:00
Mohamed Boudra
e98d8c7a36 Auto-title workspaces created by agents (#1887)
* Auto-name workspaces created by agents

* Require workspace auto-namer in websocket harnesses

* Auto-title checkout worktrees without branch renames
2026-07-04 09:52:00 +02:00
Mohamed Boudra
9c220c2451 Browser tools: full accessibility snapshot, trusted input, dialogs, evaluate, and tab controls (#1881)
* feat(browser): replace flat snapshot with ARIA tree and page-side refs

* feat(browser): trusted CDP input with actionability waits

* feat(browser): auto-handle javascript dialogs

* feat(browser): add browser_evaluate

* feat(browser): add scroll, resize, and close_tab tools

* fix(browser): review fixes for snapshot/input/dialog seams

* fix(browser): address review findings on dialog capture, truncation, and keypress targets

* fix(harness): drop resize check that cannot run under parked compositor surfaces

* fix(browser): second review round — snapshot fidelity, key input, dialog scoping
2026-07-04 01:38:07 +02:00
Mohamed Boudra
b28f291493 Generalize browser automation hosting beyond the desktop app (#1878)
* feat(browser): generalize browser automation hosts

Rename the browser automation client capability to a host-neutral payload, advertise supported commands, and route tab-scoped commands by browserId affinity so future browser hosts can register without another protocol shape change.

* fix(browser): harden browser host registration

Reject unusable browser host capabilities, clean up pending requests when a host registration is replaced, and defer list_tabs affinity updates until every host in the aggregation succeeds.

* fix(browser): preserve host tab affinity across reconnects

Keep stranded tab ownership tied to the disconnected host so the same host can reclaim open tabs after reconnect, and tolerate future command names in browser_host capability payloads while retaining only known commands.
2026-07-04 01:46:04 +08:00
Mohamed Boudra
f226222638 Build Docker images from source (#1877)
* fix(docker): build images from source

Collapse the npm-install and source-build image paths into one Dockerfile so every Docker image is built from the checked-out workspace packages. Include procps in the runtime image because provider process cleanup depends on ps through tree-kill on Linux.

* fix(docker): include native speech packages

* fix(docker): cover source build inputs

* fix(docker): ignore local agent artifacts
2026-07-03 15:49:23 +02:00
Mohamed Boudra
6ce4e3f507 Let agents rename workspaces (#1876)
* feat(agent): let agents rename workspaces

* fix(agent): scope workspace rename tool

* fix(agent): allow explicit workspace renames
2026-07-03 15:41:01 +02:00
Mohamed Boudra
8d3a43e05d Stop new browser tabs from an agent stealing your focus (#1875)
* fix(browser): stop new MCP browser tabs stealing focus

browser_new_tab used openTabFocused, switching the workspace UI to
the new tab and interrupting whatever the user was looking at.
Automation commands don't need visual focus to work: webviews stay
resident and paintable regardless, so open the tab in the background
via openTabInBackground instead.

* fix(browser): stop background MCP tabs hijacking Reload

openBrowserTabForRequest still called setWorkspaceActiveBrowser for
tabs opened in the background, so the desktop Reload/Force Reload
menu (which targets getMostRecentWorkspaceActivePaseoBrowserWebContents)
could reload an agent's hidden tab instead of whatever the user was
looking at. Automation commands never needed this: every tab-scoped
MCP tool takes an explicit browserId, and the existing
useSyncWorkspaceActiveBrowser hook already keeps "active browser" in
sync with real UI focus. Drop the redundant call.
2026-07-03 13:19:24 +02:00
Mohamed Boudra
7cb0ccba60 Show Claude Ultra Code where supported (#1872)
* fix(providers): show Claude Ultra Code by capability

Expose Ultra Code for xhigh-capable Claude models and keep first-party Claude model metadata in one manifest so future model updates do not need scattered feature lists.

* fix(providers): preserve Claude fast mode aliases

Normalize versioned Claude model IDs through the manifest before checking fast-mode support so dated Opus IDs keep the same feature surface as their base model.

* fix(providers): keep Claude fast mode first-party

Anchor Claude model normalization so custom settings models that merely contain first-party IDs do not inherit manifest-only fast mode.

* fix(providers): preserve Claude runtime aliases

Keep Fast Mode capability checks strict to manifest IDs while allowing provider-form Claude runtime model strings to resolve known model metadata.
2026-07-03 18:49:59 +08:00
paseo-ai[bot]
f1a8e9a563 fix: update lockfile signatures and Nix hash [skip ci] 2026-07-03 10:02:44 +00:00
Mohamed Boudra
75b11ac93e chore(release): cut 0.1.104-beta.2 2026-07-03 11:58:21 +02:00
Mohamed Boudra
8680bc2236 docs: changelog for v0.1.104-beta.2 2026-07-03 11:44:51 +02:00
Mohamed Boudra
e67e25628b Merge branch 'main' of github.com:getpaseo/paseo 2026-07-03 11:43:17 +02:00
Mohamed Boudra
860fe92fdd fix(browser): normalize resident webview reuse
Resident nodes restored from the parking host could carry stale offscreen styles after hot reload, so reapply the permanent parking path before returning them.
2026-07-03 11:42:39 +02:00
Mohamed Boudra
ab62a023fd fix(browser): park webviews permanently paintable
Delete the renderer capture prep handshake and keep screenshots on the serialized invalidate/retry path. The capture harness now defaults to the production P1 attach-off parking check.
2026-07-03 11:34:48 +02:00
Mohamed Boudra
cc21262130 Extend browser capture parking harness 2026-07-03 11:12:38 +02:00
paseo-ai[bot]
2a226b3872 fix: update lockfile signatures and Nix hash [skip ci] 2026-07-03 06:31:30 +00:00
Mohamed Boudra
a13fa4e614 chore(release): cut 0.1.104-beta.1 2026-07-03 08:27:07 +02:00
Mohamed Boudra
ccd2bb2bf3 docs: tighten beta changelog browser bullet 2026-07-03 08:25:36 +02:00
Mohamed Boudra
570e652496 chore: update ACP provider catalog version pins 2026-07-03 08:17:17 +02:00
Mohamed Boudra
6d55565d19 docs: changelog for v0.1.104-beta.1 2026-07-03 08:16:12 +02:00
Mohamed Boudra
fec723c775 Retry browser screenshots until first frame 2026-07-03 08:02:59 +02:00
Mohamed Boudra
1bf50349a5 Prefer resident webview for screenshot prep 2026-07-03 07:50:34 +02:00
Mohamed Boudra
cda1ba43cc Fix fresh browser screenshot prep race 2026-07-03 07:18:45 +02:00
Mohamed Boudra
f22865df62 Fix multi-webview browser capture prep 2026-07-03 07:00:06 +02:00
Mohamed Boudra
33ffeb4d42 Harden browser capture prep cleanup 2026-07-03 03:53:53 +02:00
Mohamed Boudra
6781521c8e Test Codex MCP images through app-server boundary 2026-07-03 01:16:57 +02:00
Mohamed Boudra
4ca89f21f9 test(browser-tools): fix stale pageInfoCommand references in broker test 2026-07-03 00:38:00 +02:00
Mohamed Boudra
9430a897e8 Fix browser screenshot capture for parked webviews 2026-07-03 00:27:35 +02:00
Mohamed Boudra
b0088303fd Render MCP screenshot images without timeline base64 2026-07-03 00:03:44 +02:00
Mohamed Boudra
2ba3ad53ab feat(browser-tools): cut low-value tools 2026-07-02 23:56:20 +02:00
Mohamed Boudra
b2714ccd89 feat(browser): rework element annotation and picker toolbar
Element annotation is now free-text: dropped the Fix/Change/Question/
Approve intent pills and the intent field, so the user just says what
they want. The card uses the shared Button and is titled "Annotate
element".

Toolbar: the element picker and DevTools are no longer dev-gated and
work in prod; DevTools uses a wrench icon, and the old clipboard "grab"
is now a Camera "Screenshot element" tool. Each toolbar button
highlights only its own mode, and the copy toast reflects whether a
screenshot image was actually captured.
2026-07-02 23:20:57 +02:00
Mohamed Boudra
82993c2197 test(mcp-server): assert listing invariant via public catalog 2026-07-02 22:31:22 +02:00
Mohamed Boudra
742d47fe71 fix(browser-tools): restore MCP-compatible outputs
Browser tools were registering union output schemas that the MCP SDK could not validate at call time. Use raw object output shapes and cover the real tools/call boundary.
2026-07-02 22:14:26 +02:00
Mohamed Boudra
441b935b73 test(browser-tools): restore automation coverage 2026-07-02 20:38:48 +02:00
Mohamed Boudra
b2ad903005 Fix browser tool ergonomics followups 2026-07-02 20:18:22 +02:00
Mohamed Boudra
554e525186 Tighten browser tool schemas 2026-07-02 20:00:17 +02:00
Mohamed Boudra
71ce96b60f refactor(browser-tools): require explicit tab targets
Remove hidden active-browser targeting so automation requests carry workspace in the envelope and browserId in tab command args.
2026-07-02 19:53:40 +02:00
Mohamed Boudra
d9615c1e98 Serialize browser screenshot capture state 2026-07-02 19:04:47 +02:00
Mohamed Boudra
6fea8305a7 Use Electron capturePage for browser screenshots 2026-07-02 18:53:57 +02:00
Mohamed Boudra
172de4fbc2 Focus browser automation new tabs 2026-07-02 18:46:57 +02:00
Mohamed Boudra
adc0c01782 Fail fast on unpainted browser screenshots 2026-07-02 18:43:54 +02:00
Mohamed Boudra
3eed8c31a2 Fix browser tool workspace context 2026-07-02 18:39:10 +02:00
Mohamed Boudra
58fce6622b Merge branch 'main' of github.com:getpaseo/paseo 2026-07-02 18:22:13 +02:00
Mohamed Boudra
27f1f1d207 fix(app): align schedules screen shell with app conventions
Move the New schedule button out of the screen header (the only screen
using MenuHeader rightContent) into the content filter row, and match the
History screen's full-width layout instead of the 720px centered column.

Tighten the shared sm SegmentedControl and Button vertical padding so the
status switcher and button sit flush with the host-filter pill; this is a
global sm change and also affects settings and the schedule form sheet.
2026-07-02 15:41:08 +02:00
Christoph Leiter
807d0d6d69 fix(app): show New workspace action on non-git sidebar projects (#1857)
* fix(app): show New workspace action on non-git sidebar projects

The per-project "New workspace" affordance in the sidebar (the project-
header + button and the empty-project ghost row) was gated on
host.canCreateWorktree, i.e. projectKind === "git". Non-git projects
(non_git / directory) therefore showed no way to add a workspace, even
though a host with the workspaceMultiplicity capability can create
additional local workspaces for them.

Thread the per-host workspaceMultiplicity flag into the sidebar project
row model and show the affordance when canCreateWorktree ||
supportsWorkspaceMultiplicity, matching the gate already used by the
global "New workspace" button and the Cmd+N handler. Rename the internal
trailing-action kind new_worktree -> new_workspace (a client-only UI
model, not a wire type) to reflect that it now also covers non-git
workspaces.

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

* test(app): expect the per-row New workspace icon on non-git projects

The sidebar "+" now shows for non-git projects on a multiplicity-capable
host, so the Model B sidebar spec no longer asserts the non-git project
has zero new-worktree icons — it now expects the icon, like git projects.
Also refresh the now-stale "no new-worktree icon" comments in the
entry-points spec (the picker is still one valid entry point).

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 21:15:10 +08:00
Mohamed Boudra
6513b56571 Polish schedules list and editing (#1860)
* feat(schedules): polish schedules list and editing

Adds host filtering, status grouping, and target resolution to the
schedules screen. Extracts a shared HostFilter from sessions. Improves
schedule editing with project targets and model hydration. Handles
missing targets server-side instead of retrying forever.

* test(mcp): update schedule mocks for createOrReplace

* fix(schedules): only show host name when multiple hosts exist
2026-07-02 12:05:35 +02:00
Mohamed Boudra
2263469342 fix(server): show Claude usage when a quota window has no scheduled reset (#1855)
The Anthropic usage API returns resets_at: null when a window has no
active session. The schema only allowed string/undefined, so the parse
threw and the whole Claude provider surfaced as an error in the quota
panel. The consuming code already handled null; only the schema was
stricter than the API.
2026-07-02 08:30:36 +02:00
Xisheng Parker Zhao
5a0ea3385e feat(app): add ByteDance TRAE CLI to the ACP provider catalog (#1831)
* feat(app): add ByteDance TRAE CLI to the ACP provider catalog

Adds the official ByteDance TRAE CLI (traecli) to the in-app ACP provider
catalog. traecli is ACP-native, so Paseo drives it over the standard ACP
transport via `traecli acp serve`, reusing the generic ACP client exactly
like Kiro, Qoder, Cursor, and Gemini.

- catalog: traecli entry (command ["traecli","acp","serve"], manual install)
- icon: vendored TRAE monogram SVG + registered icon name
- docs: supported-providers list + CHANGELOG
- tests: focused command + icon-name assertions

Verified against the real traecli ACP surface in multica-ai/multica#4724.

* feat(app): use the real TRAE app logo for the traecli provider icon

* docs: drop TRAE CLI changelog entry

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-07-01 22:50:15 +02:00
paseo-ai[bot]
3708eddbcd fix: update lockfile signatures and Nix hash [skip ci] 2026-07-01 20:42:13 +00:00
Mohamed Boudra
f41dde8c72 chore(release): sync Sonnet 5 stable release to main 2026-07-01 22:36:37 +02:00
Mohamed Boudra
d3e8f77914 Keep composer autocomplete visible after route hops (#1851)
* fix(app): keep composer autocomplete visible after route hops

Workspace callers now express only the target workspace. The navigation helper chooses whether to pop to a mounted host route or fall back, and active-workspace tracking ignores stale hidden route params while app-wide routes are foregrounded.

* test(app): address workspace navigation review

* fix(app): parse decoded legacy workspace routes

* fix(app): ignore decoded legacy tab routes

* test(app): use canonical offline workspace route
2026-07-01 21:55:17 +02:00
Mohamed Boudra
48d07dedb5 Keep New Workspace drafts when archiving a workspace (#1838)
* fix(app): preserve new workspace drafts across archive

The New Workspace project picker was still tied to the last active workspace. Archiving that workspace could make the picker fall back to another project, changing the draft key and clearing the composer.

* refactor(app): extract new workspace project selection

Keep the archive-draft fix out of the screen effect by moving the project selection transition into a small pure model with focused coverage.

* test(app): seed host in archive draft regression

Make the new Playwright regression self-contained by seeding the local host before opening the app shell.

* refactor(app): home new workspace project picker state

* fix(app): reset project picker on capability hydration

* fix(app): preserve manual project picks during hydration

* fix(app): keep project selection through archive gaps

* fix(app): narrow archive-gap project preservation

* fix(app): refresh new workspace project selection
2026-07-01 21:53:16 +02:00
Mohamed Boudra
31e9a210d0 Open a project with Cmd+O (#1849)
* feat(app): open a project with Cmd+O

Cmd+O now opens the project picker (was Cmd+Shift+O). New worktree,
which previously used Cmd+O, no longer has a keyboard shortcut; its
sidebar button still creates one.

* test(app): assert old Cmd+Shift+O open-project binding is unbound

Locks in the rebind to Cmd+O so re-adding a Cmd+Shift+O binding would fail CI.

* fix(app): keep Open project override id stable and forward Cmd+O in desktop browser

- Binding ids for Open project keep their original names so existing user
  shortcut overrides (keyed by binding id) survive the Cmd+Shift+O -> Cmd+O rebind.
- Forward "o" from focused browser webviews in the desktop app so Cmd/Ctrl+O
  reaches the renderer and opens the project picker there too.
2026-07-02 02:11:11 +08:00
Mohamed Boudra
bf5e3b47e7 Add a Schedules screen to manage recurring agents (#1246)
* feat: add Schedules screen to manage scheduled agents

Adds a Schedules section in the sidebar header (below Sessions) for viewing
and managing new-agent schedules: recurring jobs that spawn an agent on a
cron or interval cadence.

- Borderless table showing provider icon, model, cadence, next run, and
  status, with row actions to edit, pause/resume, run now, and delete.
- Create/edit sheet that reuses the agent provider/model/mode/working-
  directory pickers, plus an interval/cron cadence editor with presets,
  validation, and a UTC preview.
- Responsive: hover-revealed row actions on desktop, an always-visible
  action menu on mobile.
- Data layer over the existing schedule RPCs (list/create/update/pause/
  resume/delete/run-once) with optimistic pause, resume, and delete.

Scope is limited to new-agent schedules for now.

* Make the schedule form controls visually consistent

Merge the provider and model pickers into the single nested selector the
draft screen uses, render it as a full-width field, mute the placeholder,
proportion the cadence segmented controls, unify the cron preset chips,
and route the sheet scroll through the themed scrollbar.

* Address schedules review feedback

* Translate schedule hook client fallback errors

* Make schedules global and project scoped

* Stabilize project settings transport E2E

* Reset schedule model on cross-host project change

* Reject malformed cron step expressions

* Tighten schedules project query freshness

* refactor(schedules): clean up review feedback

* test(schedules): extract fake host setup

* refactor(schedules): share cron expression parsing

Use one structural cron parser for app previews and daemon cadence validation so the accepted grammar cannot drift between surfaces.

Remove the unused host-scoped schedules redirect while keeping the global schedules route.
2026-06-30 22:58:49 +02:00
huiliaoning
b8b66816ca feat(browser): inspect, annotate, and grab page elements for the agent (#1708)
* feat(browser): inspect, annotate, and grab page elements for the agent

Build a design-review flow on top of the in-app Electron browser so users
can send page elements to their coding agent with context.

- Annotate: pick an element, write a comment, choose an intent
  (fix/change/question/approve); the element context + intent + comment go
  to the agent as text, and a cropped screenshot rides along as an image.
- Grab: pick an element to copy its info + screenshot straight to the
  system clipboard (no comment), with a toast on success/failure.
- Hover inspector: in select mode each element shows a floating label with
  tag, id/class, React component name, and pixel size.
- Page markers: annotated elements on the current page get numbered badges
  that track scroll/resize.
- Device sizes: a viewport-size menu (responsive + 13 common device presets)
  renders the page centered in a fixed-size frame.
- Toolbar buttons now expose hover tooltips and no longer get squeezed out
  on narrow panes; the element selector is available to all desktop users
  (previously dev-only).

Screenshots and clipboard writes go through new Electron main-process IPCs
(capturePage + clipboard) so they work regardless of webview focus. Element
screenshots are referenced by id in the workspace attachment store and are
protected from the draft-store attachment GC. All new strings are
translated across the six supported locales.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(browser): write element clipboard exactly once

Resolve the image before writing so a combined text+image grab no longer
does a redundant text-only writeText() first (which flashed an intermediate
clipboard state). Addresses greptile review feedback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* i18n: add browser annotate/grab/devices strings for ja and pt-BR

main added Japanese and Brazilian Portuguese locales after this branch was
created; add the browser annotation, grab, and device-size keys to keep all
locales in sync with en. resources.test.ts parity passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 22:58:16 +02:00
Christoph Leiter
2b46caa20e feat(app): clearer interactive question card
Fixes #1642
2026-06-30 22:53:12 +02:00
Mohamed Boudra
20385bdb50 Add opt-in browser tools for desktop tabs
* Add opt-in browser tools for desktop tabs

Adds the daemon opt-in, desktop tab routing, MCP tools, and real browser automation surfaces for Paseo desktop browser tabs.

* Fix browser tools CI expectations

* Address browser tools review findings

* Restrict browser file automation paths

* Fix browser upload test on Windows

* Harden browser navigation inputs

* Make browser tools create usable tabs

* Update browser MCP empty-state test

* Fail browser tab creation when registration times out

* Fix browser screenshots for agents

* Hide disabled browser tools from agents

* Address browser tools architecture review

* Replace browser tools review tests

* Wrap browser tab registration errors

* Mock Expo Router in app unit tests

* Handle invalid browser automation requests

* Return browser failure on desktop disconnect

* Update browser disconnect websocket test

* Relax browser timeout polling test

* Handle invalid browser responses

* Return browser failure when send fails

* Remove local diagnostics and fixture paths

* Fix dev service home fallback

* Use worktree home for dev services

* Use managed daemon in desktop dev

* fix(browser): keep agent tabs addressable

Track agent-active browser targets separately from human-focused tabs and keep resident webviews alive for automation. Browser tool visibility now comes from registration while the broker reports disabled execution.

* refactor(browser): register tools through catalog

Move browser tool registration onto the shared Paseo tool catalog so the MCP server remains only the transport adapter.

* fix(settings): translate browser tools host error
2026-06-30 22:33:18 +02:00
Mohamed Boudra
db03b1f3fd docs: link paseo-vscode extension 2026-06-30 16:18:03 +02:00
Mohamed Boudra
cb486c3a5a Speed up app Playwright CI (#1830)
* ci(playwright): shard app e2e in CI

Run the app Playwright suite across isolated CI shards and keep restarted E2E daemons on the same speech-disabled setup path as global setup.

* test(app): share disabled speech e2e env
2026-06-30 12:37:47 +02:00
paseo-ai[bot]
7dad7a377c fix: update lockfile signatures and Nix hash [skip ci] 2026-06-30 10:05:14 +00:00
Mohamed Boudra
e32c50f2d7 chore(release): cut 0.1.102 2026-06-30 12:00:31 +02:00
Mohamed Boudra
e3eb633582 docs: update changelog for 0.1.102 2026-06-30 11:23:41 +02:00
Mohamed Boudra
beac4544fd chore: update ACP provider catalog 2026-06-30 11:22:12 +02:00
Mohamed Boudra
c2271ff0ca Place turn footers after trailing tools (#1827)
* fix(app): place turn footers after trailing tools

Completed turn footers now render on the last visible item before the next user message while still using the latest assistant message for footer content and timing.

* test(app): cover split turn footer placement
2026-06-30 10:51:53 +02:00
Mohamed Boudra
f91806defe Use separate OpenAI endpoints for speech-to-text and text-to-speech (#1823)
* feat(voice): configure OpenAI STT and TTS endpoints separately

Replace providers.openai.voice (a single apiKey/baseUrl shared by
speech-to-text and text-to-speech) with independent providers.openai.stt
and providers.openai.tts, each carrying its own apiKey/baseUrl. STT and
TTS now resolve fully independently, so they can point at different
OpenAI-compatible endpoints. The env equivalents OPENAI_VOICE_API_KEY /
OPENAI_VOICE_BASE_URL split into OPENAI_STT_* and OPENAI_TTS_*.

No backcompat: the voice key is removed and no longer read. Each feature
still falls back to providers.openai.apiKey/baseUrl, then OPENAI_API_KEY/
OPENAI_BASE_URL. Composer dictation resolves from the STT endpoint.

* fix(voice): keep daemon bootable and respect global OpenAI key

Two issues from review of the STT/TTS endpoint split:

- A config from an older release that still sets providers.openai.voice
  crashed daemon startup, because the strict schema rejects the now-unknown
  key. Strip it before parsing (alongside the existing local.autoDownload
  strip) so the daemon boots; the value is discarded, not migrated.
- An empty endpoint env var (e.g. a copied .env.example leaving
  OPENAI_STT_API_KEY= blank) shadowed the OPENAI_API_KEY fallback, so
  speech was reported as missing credentials despite a configured global
  key. firstDefined now skips empty/whitespace strings.

* fix(voice): isolate STT and TTS option parsing per endpoint

An STT-only OpenAI setup could be broken by a stale or invalid TTS env
var (e.g. a leftover TTS_VOICE/TTS_MODEL), because the single resolution
schema validated both endpoints' option groups before the per-endpoint
gate. Split into endpoint-key, STT-option, and TTS-option schemas and
parse each option group only when that endpoint has credentials, so an
unused endpoint's bad env can no longer take down the configured one.

* fix(voice): tag voice-config shim and update direct-daemon test callers

- Mark the providers.openai.voice strip with a COMPAT(openaiVoiceConfig)
  comment + removal date so it shows up in the back-compat cleanup
  inventory, per repo convention.
- Update the tests that build the daemon directly with a resolved OpenAI
  config (bootstrap smoke + the real-API voice/daemon e2e suites) to the
  new { stt, tts } shape; the old top-level { apiKey } is no longer read,
  and these files are excluded from typecheck so the break was silent.

* fix(voice): update voice-roundtrip debug script to new OpenAI config shape

Last direct daemon caller still passing the removed top-level
openai: { apiKey }; the debug script lives outside tsconfig.scripts.json
so the stale shape wasn't caught by typecheck. Use { stt, tts }.
2026-06-30 10:00:27 +02:00
Mohamed Boudra
e9431517a0 Show host in search results and filter sidebar by multiple hosts (#1825)
* feat(app): show host in command center and multi-select host filter

Command-center agent results now show which host the workspace lives on,
shown only when more than one host is connected (matching the sidebar).

The sidebar display-preferences filter becomes multi-select — pick any
number of hosts instead of one-or-all — and its host rows now carry a
status dot on the left like the other host pickers. Persisted filter
state migrates from the old single host to a host list.

* test(app): extract host-filter and command-center e2e DSL helpers

Move the mechanical menu/search interactions out of the new spec bodies
into focused helpers so the tests read as user intent.

* fix(app): drop the icon from the host filter "All hosts" item

* fix(app): make multi-host e2e seeding order-independent

The offline-host seed relied on Playwright running the test's init script
after the fixture's registry reset — an ordering Playwright does not
guarantee. Write the full registry and set the fixture's disable-once flag,
then reload, so the multi-host seed survives regardless of script order.

Also tag the pre-v2 hostFilter migration reader with COMPAT so the cleanup
sweep can find it.
2026-06-30 09:40:24 +02:00
Xisheng Parker Zhao
4e28b0941b fix(server): surface Kiro CLI slash commands and skills over ACP (#1792)
* fix(server): surface Kiro CLI slash commands and skills over ACP

Kiro CLI advertises its slash commands and skills through the
`_kiro.dev/commands/available` ACP extension notification rather than the
standard `available_commands_update` session update. ACPAgentSession only
trace-logged extension notifications and dropped the payload, so the
composer never showed any Kiro commands or skills.

- Map the extension payload onto the shared slash-command cache:
  `commands` (built-in, names arrive with a leading "/") and `prompts`
  (skills/prompts, tagged with a `skill:` serverName) are normalized to
  Paseo's AgentSlashCommand shape, stripping the leading slash and pulling
  the argument hint from `meta.hint`.
- Add KiroACPAgentClient (mirrors CursorACPAgentClient) so the provider
  waits for the first async command batch before listCommands() resolves,
  since Kiro emits the notification shortly after session/new.

* fix(server): settle on empty Kiro batch; inject Kiro commands via constructor option

Addresses review feedback on #1792.

Keep vendor-specific providers out of the generic acp-agent.ts, following
the same pattern as Cursor/Copilot: provider behavior is injected through
ACPAgentClient constructor options rather than special-cased in the base
class. Adds an optional extensionCommandsParser option on the generic ACP
client/session; the base extNotification() invokes it and routes any parsed
commands through a generic applyResolvedCommands() helper. Kiro supplies
parseKiroExtensionCommands (recognizes _kiro.dev/commands/available); the
base class no longer references any Kiro method string or payload shape.

Also fixes an empty-batch hang: applyResolvedCommands() always settles the
initial-commands gate, so a Kiro user with no slash commands no longer
blocks listCommands() for the full 10s timeout. Adds a regression test.
2026-06-30 09:36:48 +02:00
Mohamed Boudra
7c6152663e Show found desktop updates after manual checks (#1815)
* fix(desktop): report found app updates during manual checks

Manual checks could reuse cached update state while the renderer discarded pending update details. Keep found update versions visible while downloads prepare, and reserve install affordances for ready updates.

* test(desktop): cover manual update retry after errors

* fix(desktop): keep ready updates ready on recheck
2026-06-30 08:12:15 +02:00
paseo-ai[bot]
5fc53c576e fix: update lockfile signatures and Nix hash [skip ci] 2026-06-29 22:07:09 +00:00
Mohamed Boudra
8eb9640f28 chore(release): cut 0.1.102-beta.2 2026-06-30 00:02:06 +02:00
Mohamed Boudra
0798f997c3 docs: update changelog for 0.1.102-beta.2 2026-06-29 23:59:58 +02:00
Mohamed Boudra
68a169b29d Update ACP provider catalog 2026-06-29 23:27:27 +02:00
Mohamed Boudra
b571874852 Update fork menu tooltip and icons
- Change tooltip label from "Fork message" to "Fork chat"

- Use Split icon for both "Fork in a new tab" and "Fork in a new workspace" menu items
2026-06-29 23:25:05 +02:00
Mohamed Boudra
ad803005c8 feat(app): show the connection in use on host switcher rows
The old marker only ever tagged the local host as "Local" based on its
server id. Each row now shows the connection actually in use, inline
after the host name — the TCP or relay endpoint (default :443/:80
stripped), or "Local" for socket/pipe transports.
2026-06-29 23:11:29 +02:00
Mohamed Boudra
86775c18f3 fix(app): hide turn actions during active tool calls
Tool rows can arrive before a running turn resumes text. Treat those rows as part of the active turn so copy/fork actions wait until the turn closes.
2026-06-29 23:11:29 +02:00
Mohamed Boudra
cf2fccd01d Keep agent lists working when project records go stale (#1812)
* fix(server): keep agent fetch RPCs resilient

Agent directory projection assumed every workspace still had a parent project. A stale workspace/project record could reject the whole fetch_agents RPC, so missing placement now behaves like no placement and direct fetches can still return the agent.

* test(server): cover stale agent history fetches
2026-06-29 23:00:40 +02:00
Mohamed Boudra
811f52395e Fix Windows image previews (#1811)
* fix(images): preserve Windows preview paths

Provider image markdown used escaped backslash paths on Windows, which markdown parsing could turn into encoded drive paths that the app treated as workspace-relative. Emit drive-letter paths as file URIs and normalize existing encoded local paths before file preview.

* test(server): accept file URI image sources

Windows provider image output now renders drive-letter paths as file URIs. Decode those markdown sources before filesystem assertions so the image rendering tests keep checking the materialized file.

* fix(images): keep plain local paths literal

Limit legacy URI decoding to markdown-encoded Windows drive paths so ordinary local paths with percent sequences remain unchanged. Document why provider image markdown matching still accepts old doubled-backslash history.
2026-06-29 20:49:13 +00:00
Mohamed Boudra
df36a958f8 Make daemon status report health without loading agents (#1810)
* fix(cli): avoid agent inventory in daemon status

* fix(cli): keep daemon status reachable on detail failure
2026-06-29 20:40:11 +00:00
Mohamed Boudra
111fdb81fd Show desktop update check feedback (#1808)
* fix(desktop): show app update check feedback

Manual desktop app update checks now leave visible status feedback even when the shared update state is pending or available. Updater check and preparation errors are carried through the existing result path so the settings row and callout can show the failure instead of only logging it.

* fix(desktop): make update retries perform fresh checks

Manual retries now clear runtime errors emitted by a failed check so the next click calls the updater again. Background checks also skip while a visible manual check is active, and last-checked update copy uses complete localized strings.

* fix(desktop): punctuate update check timestamp copy

* fix(desktop): share runtime update errors

* fix(desktop): preserve update preparation errors

* fix(desktop): settle update check review races

* fix(desktop): settle quiet update check errors

* fix(desktop): handle overlapping update checks

* fix(desktop): preserve preparation errors during checks
2026-06-29 21:00:42 +02:00
yz
efd7ab3420 Fix macOS packaged CLI daemon Dock icons (#1759)
* Fix macOS CLI daemon relaunch path

* test(cli): mock helper existence in daemon launch test

* fix(desktop): launch packaged CLI through Helper

The packaged macOS CLI shim entered through the main app executable, so daemon supervision inherited app lifecycle behavior and surfaced Dock icons. Make Helper the required macOS CLI runtime, keep daemon relaunches on process.execPath, and cover cold bundled CLI daemon starts in release smoke.

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-06-29 20:45:53 +02:00
Mohamed Boudra
f94ec0e430 Keep New Workspace on the current project (#1806)
* fix(app): keep New Workspace on the active project

New Workspace preselection was falling back to stale remembered host state when multiple hosts existed. Pass durable active-project context from workspace entry points and centralize initial host selection so stale offline hosts cannot steal the form while startup restore remains last-workspace only.

* fix(app): address New Workspace review feedback

* fix(app): stabilize New Workspace host selection

* fix(app): preserve New Workspace host fallback

* test(app): loosen stale-host project assertion

* fix(app): prefer reachable new workspace hosts

* fix(app): avoid stale offline workspace preselect

* fix(app): prefer online project hosts
2026-06-29 20:44:18 +02:00
Mohamed Boudra
0614618d2f Merge branch 'main' of github.com:getpaseo/paseo 2026-06-29 20:24:57 +02:00
Mohamed Boudra
f31bc2b8b7 Fix pushed state for checked-out PR worktrees (#1804)
* fix(worktree): track checkout PR branches correctly

Checkout-PR worktrees could point branch tracking at a PR push remote without fetching a matching remote-tracking ref. Configure same-repo PRs to track origin, make fork PR remotes fetchable before assigning upstream, and push fork PR branches to their configured remote.

* fix(worktree): scope PR push routing to Paseo targets

Review caught that using ordinary upstream config for Push could send normal branches to non-origin remotes and mishandle deduped PR branch names. Store a Paseo-specific push target during PR worktree creation, then have Push honor only that marker before falling back to origin.

* fix(worktree): use git upstream for PR pushes

* fix(worktree): allow PR checkout without head upstream

* fix(worktree): preserve fork PR git push target

* fix(worktree): preserve PR push targets

* fix(worktree): preserve PR push status

* fix(worktree): set upstream after push-only PR push
2026-06-29 20:21:19 +02:00
paseo-ai[bot]
31d14ec8a7 fix: update lockfile signatures and Nix hash [skip ci] 2026-06-29 17:43:59 +00:00
Mohamed Boudra
a1afdfee51 Fix Claude subagent narration leaking into chat (#1807)
* fix(claude): keep subagent text out of parent transcript

Sidechain tool activity is folded into the parent Task/Agent row, but sidechain assistant text was still going through the top-level text assembler. That made the chat show subagent narration without its matching tool rows.

* test(claude): simplify subagent sidechain assertion
2026-06-29 19:40:15 +02:00
Mohamed Boudra
b5559dc1de Keep streamed chat images in order (#1805)
* fix(app): preserve assistant stream order

Assistant head flushing only considered item kind, so a new identified assistant message could promote ahead of earlier live rows. Flush the live head when assistant message ids diverge so live rows remain an ordered suffix.

* refactor(app): reuse assistant message id helper
2026-06-29 16:38:52 +00:00
Mohamed Boudra
85acaceb16 Fork assistant turns into new drafts (#1788)
* feat(chat): fork assistant turns into new drafts

Builds chat-history attachments on the daemon and routes them into draft composers so users can continue an assistant turn in an existing workspace tab or New Workspace.

* fix(chat): clean up fork draft handling

* fix(chat): address fork draft review feedback

* fix(server): build fork context from bounded timeline

* fix(app): restore assistant turn footer in streams

* fix(app): preserve fork draft setup

* fix(app): tighten assistant fork footers

* fix(app): lock active fork draft handoff

* fix(app): preserve attachment-only draft retries

* fix(app): unify composer attachment scopes

* fix(app): align chat history attachment labels

* fix(app): centralize attachment pill content

* fix(app): trim fork context for workspace naming
2026-06-29 18:29:59 +02:00
Mohamed Boudra
e63a971968 Make daemon shutdowns easier to diagnose (#1790)
* fix(daemon): log stop reasons and client identity

Record websocket client identity, process memory/uptime, and shutdown reasons across CLI, desktop, supervisor, and worker paths so daemon drops can be traced from the triggering client to worker termination.

* fix(daemon): keep shutdown diagnostics in sync

Test drift: supervisor and relay tests still asserted the old log text and metadata shape after shutdown diagnostics started logging structured reasons and relay connection ids. Update those assertions to the new diagnostic contract.

Also centralize client lifecycle reason normalization and derive desktop daemon stop reasons from one tuple so future changes cannot silently drift.
2026-06-29 23:31:11 +08:00
Mohamed Boudra
b613bea9f6 Default client RPC waits to 60 seconds (#1789)
* fix(client): wait longer for session responses

* fix(client): default RPC waits to sixty seconds

* fix(app): preserve detached stream scroll on delayed history

Code drift: longer client RPC waits let delayed timeline responses arrive after a user scroll-away, so web stream anchoring must not reattach on transient scroll-top resets.

Restore the 15s connect deadline and leave app initialization slack above the default 60s session RPC wait.

* fix(client): keep helper waits within caller deadlines

Review fix: the 60s default session RPC wait leaked into wait previews and waitForAgentUpsert helper fetches. Bound those helper RPCs to the caller deadline or a short best-effort preview timeout, and allow small scroll ranges to reattach at bottom.

* fix(client): respect caller timeout budgets

* fix(cli): keep diagnostic probes responsive

* Refactor daemon client request options

* Preserve daemon client legacy overloads
2026-06-29 17:23:18 +02:00
Mohamed Boudra
57800a0f17 Restore the "Drop files here" backdrop when dragging files (#1801)
Decouples file drag-and-drop into a FileDropZone provider (drag target + backdrop) and a useFileDrop consumer hook, with drag state on a Reanimated shared value so dragging triggers no React re-renders.

Fixes the collapsed/invisible backdrop (the drop zone had been moved into the composer with flex:0, collapsing the overlay to zero height) and restores whole-surface coverage across the agent panel, New Workspace screen, draft workspace tab, and workspace-setup dialog. The backdrop and copy cursor only appear when a drop will actually be accepted (a consumer is mounted and not submitting).

Refs #520
2026-06-29 16:55:45 +02:00
Mohamed Boudra
0d1c8db87d fix(settings): align host switcher with sidebar rows
The host picker reused the composer trigger's content-width pill, so its
label sat 6px left of the section rows and the chevron hugged the label
instead of the trailing edge. Add a `block` mode to ComboboxTrigger that
fills the width and uses the sidebar-row gap, and size the status dot to
the icon footprint so the label and chevron line up with the rows below.
2026-06-29 12:16:20 +02:00
agamotto
6a23bbd121 Fix Windows OpenCode process cleanup 2026-06-28 19:11:22 +07:00
Mohamed Boudra
3288e1cfb9 Stop agent prompts renaming existing workspaces 2026-06-28 19:10:59 +07:00
Mohamed Boudra
cb212b4e5c Fix mobile launch with a saved workspace 2026-06-28 19:10:39 +07:00
Mohamed Boudra
4968969c87 Preserve chat scroll-away after delayed history 2026-06-28 19:10:12 +07:00
Mohamed Boudra
96573d0bcc Keep slash commands visible after New Workspace (#1760)
* fix(app): keep slash commands visible after New Workspace

Repeated returns from the app-wide New Workspace route were using dismissTo with the workspace leaf URL. That updated the root URL without popping the nested host stack, so hidden duplicate workspace deck entries could remain mounted and steal composer popover measurements.

Dispatch a root-stack POP_TO into the host workspace route instead, with a Playwright regression for the slash-command popover.

* test(app): tighten workspace autocomplete regression

Derive the expected deck count from the seeded workspaces and make the duplicate-deck assertion hard. Keep the Expo Router pop hint documented because removing it reproduces the hidden deck entries.
2026-06-28 03:01:01 +00:00
Mohamed Boudra
9b6aa99396 feat(sidebar): show host name on every row in multi-host setups (#1775)
The host name previously appeared only in project grouping mode, and only
for projects shared across hosts. It now shows on every workspace row in
both project and status grouping modes whenever the sidebar spans more than
one host, so you can always tell which machine a workspace lives on. Status
rows read "Project · Host". Single-host setups are unchanged.
2026-06-28 08:50:04 +07:00
Mohamed Boudra
276c1f48f1 ci: add Docker-only publish dispatch [skip ci] 2026-06-27 09:49:20 +00:00
Mohamed Boudra
821d194fbe fix: allow Docker web UI loopback aliases 2026-06-27 09:28:34 +00:00
Mohamed Boudra
ab3ed56513 fix: repair beta Docker image 2026-06-27 09:09:52 +00:00
Mohamed Boudra
62bdb52eaa ci: build beta Docker images from source 2026-06-27 05:17:51 +00:00
paseo-ai[bot]
d7b24ab124 fix: update lockfile signatures and Nix hash [skip ci] 2026-06-27 05:12:01 +00:00
Mohamed Boudra
c7e27fdd5b chore(release): cut 0.1.102-beta.1 2026-06-27 05:08:20 +00:00
Mohamed Boudra
fc52b2812c docs: update changelog for 0.1.102-beta.1 2026-06-27 05:08:01 +00:00
Mohamed Boudra
47a71631e8 chore: update ACP provider catalog 2026-06-27 05:07:58 +00:00
Mohamed Boudra
696a9f0119 Show Add Project search loading state (#1762)
* fix(app): show add project search loading state

The Add Project picker now treats a typed value that is ahead of the debounced directory search as an active search, so empty results show a loading message instead of the initial prompt or a blank list.

* fix(app): avoid overlapping project picker states
2026-06-27 12:44:59 +08:00
huiliaoning
26a8a04651 fix(projects): make freshly-added projects editable
Make workspace-less projects flow through aggregated project fetching and keep the settings screen editable immediately after adding one.
2026-06-27 12:44:16 +08:00
Slava Goltser
a908384e5a fix(acp): add tests asserting cwd and mcpServers are always passed to session/load (#1593) (#1624)
* fix(acp): add tests asserting cwd and mcpServers are always passed to session/load (#1593)

Add unit tests in acp-agent.test.ts that verify initializeResumedSession()
always calls loadSession and unstable_resumeSession with { sessionId, cwd,
mcpServers } — even when mcpServers is an empty array. Some strict ACP
providers (e.g., Devin CLI) return 'Invalid params' if any of these fields
are omitted.

Also adds a docstring above initializeResumedSession() documenting this
requirement so future refactors don't accidentally drop params.

Closes #1593

* fix(acp): address Greptile review on session/load invariant tests

- Extract shared makeTestSession() factory to eliminate duplicated TestSession
  class definitions across all three tests (concern #1)
- Pass handle through typed constructor option instead of casting private
  initialHandle field (concern #2)
- Add missing type imports for AgentCapabilityFlags and AgentPersistenceHandle

* fix(tests): ensure ACP agent session load invariant
2026-06-27 02:54:33 +00:00
Slava Goltser
24a1f1b8b9 feat(pi): make extension result timeout configurable via provider params and increase default to 30s (#1732)
* feat(pi): make extension result timeout configurable via provider params

* fix(acp): add tests asserting cwd and mcpServers are always passed to session/load (#1593)

Add unit tests in acp-agent.test.ts that verify initializeResumedSession()
always calls loadSession and unstable_resumeSession with { sessionId, cwd,
mcpServers } — even when mcpServers is an empty array. Some strict ACP
providers (e.g., Devin CLI) return 'Invalid params' if any of these fields
are omitted.

Also adds a docstring above initializeResumedSession() documenting this
requirement so future refactors don't accidentally drop params.

Closes #1593

* fix(acp): address Greptile review on session/load invariant tests

- Extract shared makeTestSession() factory to eliminate duplicated TestSession
  class definitions across all three tests (concern #1)
- Pass handle through typed constructor option instead of casting private
  initialHandle field (concern #2)
- Add missing type imports for AgentCapabilityFlags and AgentPersistenceHandle

* fix(tests): ensure ACP agent session load invariant

* fix(pi): increase default extension result timeout to 30s
2026-06-27 02:53:45 +00:00
Mohamed Boudra
65f1143e6a Attach dropped files in every composer (#1750)
* fix(app): attach dropped files in composer

Composer parents had to expose attachment callbacks, so New Workspace only wired image drops and JSON files never became file attachments. Centralize dropped-file ingestion in Composer so all composer surfaces share the same image and file path.

* fix(app): handle desktop dropped attachment paths
2026-06-27 09:03:04 +08:00
Hu Sheng
f275275074 fix(server): use terminateWithTreeKill in Claude Code provider close() (#1540)
* fix(server): use terminateWithTreeKill in Claude Code provider close()

The Claude Code agent's close() method only called SDK-level cleanup
(query.close/interrupt/return) which may only kill the direct child
process. MCP server processes spawned by the Claude CLI survived as
orphans, accumulating over time and consuming significant memory.

Fix:
- Add onChildProcess callback to ClaudeQueryContext to capture the
  spawned child process reference
- Store the child process in ClaudeAgentSession
- In close(), call terminateWithTreeKill() after SDK cleanup to
  recursively terminate the entire process tree (claude + MCP children)
- Use 2s graceful (SIGTERM) / 2s force (SIGKILL) timeouts, consistent
  with other providers (ACP, Codex, Pi, OpenCode)

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

* fix(server): tree-kill old process during query restart to prevent MCP orphans

Greptile review identified a gap: ensureQuery() replaces this.childProcess
when spawning a new query without first killing the old process tree.
During reconnection events, this can leak MCP children from the
previous claude process.

Fix: add terminateWithTreeKill() in the queryRestartNeeded block
after SDK cleanup, before the replacement query spawns.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-06-26 22:02:25 +08:00
rex
5ef118ea33 fix(opencode): prevent indexing the entire home directory (#1704)
* fix(opencode): prevent indexing the entire home directory

Paseo launches opencode serve with cwd=os.homedir() and refreshes the
global provider snapshot with directory=/Users/admin. OpenCode treats
that as a workspace and starts location services + bigram indexing for
the entire home tree, causing ~466% CPU and ~4GB RAM usage.

- Use a neutral scratch directory as the opencode serve cwd.
- Use a separate scratch directory for global provider catalog refresh
  so model/mode discovery no longer triggers home directory indexing.

Fixes high CPU/RAM when Paseo starts opencode with no explicit project.

* fix(opencode): use realpath-aware matcher for home detection in catalog refresh

Switch the home-directory check in fetchCatalog from a string-based
path.resolve() comparison to createRealpathAwarePathMatcher, so we
catch macOS /private/var/... aliases, symlinks, trailing separators,
and Windows casing — consistent with the rest of opencode-agent.ts.

Also:
- Hoist the matcher to module scope so each fetchCatalog call doesn't
  rebuild it (the matcher runs realpathSync twice on construction).
- Log a debug line when we rewrite the cwd to the scratch path, so
  it's easy to diagnose missing per-directory config in catalog scope.
- Update opencode-agent.test.ts to expect the scratch directory when
  cwd === os.homedir(), with a comment pointing to the rationale.

* fix(opencode): isolate helper server home

* fix(opencode): pass semantic global catalog scope

* fix(opencode): release catalog acquisition on home resolution failure

---------

Co-authored-by: rex-chang <rex-chang@users.noreply.github.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-06-26 21:55:34 +08:00
Hu Sheng
8228bafc5d Fix web terminal scroll lag (#1622)
* fix(app): eliminate terminal scroll lag by removing MutationObserver and throttling scroll events

The MutationObserver with subtree:true was firing React setState on every
xterm DOM mutation (character output, cursor blink, scroll line rendering),
causing cascading React re-renders that blocked the main thread and made
the mouse wheel unresponsive on web.

Changes:
- Remove MutationObserver (redundant with ResizeObserver for viewport sizing)
- Throttle scroll handler with requestAnimationFrame to batch setState calls
- Early-return in updateViewportMetrics when metrics unchanged since last emit
- Cancel pending rAF on cleanup to avoid stale updates

Co-Authored-By: TommyLike <tommylikehu@gmail.com>

* Fix terminal scrollbar metric dedupe state

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-06-26 21:45:31 +08:00
Mohamed Boudra
d6b946720e fix(app): keep new workspace ref selector bounded
Long starting refs could paint past capped badges because the shared combobox trigger row kept its intrinsic width. Make the trigger row shrink-safe and clip the New Workspace badge contents.
2026-06-26 20:28:55 +07:00
Mohamed Boudra
07680cdd69 fix(app): clean up host selection affordances
Single-host users should not see host-selection chrome, while multi-host surfaces keep shared combobox feedback and consistent hover-card metadata rows.
2026-06-26 20:28:55 +07:00
Mohamed Boudra
1ce00dca1f fix(sidebar): align workspace row titles
Workspace rows inherited an extra indent in the merged sidebar, so child titles no longer lined up with project titles.
2026-06-26 20:28:55 +07:00
paseo-ai[bot]
28ea0914c5 fix: update lockfile signatures and Nix hash [skip ci] 2026-06-26 13:21:37 +00:00
dev693-ai
6277ba1ff9 feat: add C# syntax highlighting (#1651)
Wire the @replit/codemirror-lang-csharp Lezer grammar into @getpaseo/highlight following the existing per-language pattern (Java/Rust/Swift). Map the cs extension to the parser and alias the csharp and c# markdown fence names so fenced code blocks highlight.

Register csharpLanguage.parser rather than the package's raw parser export: the highlight styleTags are applied only inside csharpLanguage (via parser.configure), so the raw parser would parse but render unstyled.

Closes #1527

Co-authored-by: Clemens Wagner <wagner.clemens@gmx.de>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 13:18:15 +00:00
jms830
85322c5968 fix(client): raise project-open & directory-suggestion timeouts for large repos (#1620)
openProject 10s->60s and getDirectorySuggestions 10s->30s + debounce the
project-picker query 250ms. On large local repos the daemon's path-resolve and
home-tree scan take several seconds; firing per-keystroke against a 10s timeout
raced the suggestion list to empty (e.g. ~/gi found a hit but ~/gith blanked)
and surfaced a spurious 'Timeout waiting for message (10000ms)' on add.

Co-authored-by: jms830 <jms830@noreply.github.com>
2026-06-26 21:11:05 +08:00
Christoph Leiter
daf7042cd2 Refresh open file tabs when revisited (#445) (#1699)
File previews read once and then froze: visited tabs stay mounted-but-hidden
(LRU in useMountedTabSet), so refetchOnMount never re-fired and the only way
to see fresh content was to close and reopen the tab.

Gate the file query's `enabled` on tab + app visibility so React Query
refetches on the disabled->enabled transition — and only when the cached read
is stale (keeping the existing 5s staleTime), so a quick flip away and back
doesn't re-read. Covers switching between tabs and backgrounding/reopening the
whole app to the same tab.

Adds a wiring test covering the stale-revisit refetch, the fresh-revisit
debounce, the app-foreground refetch, and the read-error path.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 20:16:52 +08:00
Éverton Toffanetto
99643ad085 fix(server): no-op agent hooks when PASEO_TERMINAL_ID is unset (#1667)
* fix(server): no-op agent hooks when PASEO_TERMINAL_ID is unset

The shell command generated by `buildAgentHookShellCommand` short-circuited
on `[ -n "$PASEO_TERMINAL_ID" ] && ...` and exited 1 when the terminal
ID was absent, so Claude Code reported every Notification/Stop/StopFailure/
SessionEnd/UserPromptSubmit event as `Failed with non-blocking status code`.
The same shape affected the Codex provider.

Switch the gate to `if [ -n ... ]; then ...; fi` so the hook is a true
no-op when the terminal id is unset and preserves the underlying CLI's
exit code when it actually runs. Mirrors `opencode-plugin.ts`, which
already early-returns on the same env var. Adds the matching
`else (exit /b 0)` branch to `buildAgentHookWindowsCommand`.

Updates the literal-string assertions in the existing Claude and Codex
hook tests, and adds an executable regression that runs the generated
shell command through `/bin/sh` and asserts exit 0 across all five
Claude hook events.

* test(server): skip POSIX hook regression on Windows

The /bin/sh exit-code regression spawns /bin/sh -c <posix command>,
which has no equivalent on the Windows runner, so spawnSync returned
status: null and the five Claude events failed in CI.

Gate the it.each block with skipIf(isPlatform("win32")), matching the
pattern already used in worker-terminal-manager.test.ts and
executable-resolution.test.ts. The remaining literal-string and
buildTerminalEnvironment assertions in this file keep running on
every platform.
2026-06-26 16:38:25 +08:00
Davy
40265782d4 feat: add remote daemon self-update from client (#1513)
* feat: add remote daemon self-update from client

Adds a daemon.update.request RPC that lets the client trigger a remote
daemon update over WebSocket. The daemon runs npm update -g @getpaseo/cli,
reports progress via daemon_update_progress status events, then restarts
through the supervisor to load the new code.

New feature flag server_info.features.daemonSelfUpdate gates the UI.
The UpdateDaemonCard appears on the host settings page when the daemon
supports self-update and the app/daemon versions differ.

* fix: clean up progress subscription on unmount, use typed phase cast

Address Greptile review:
- P1: store unsubscribe ref and clean up on component unmount to prevent
  listener leak during in-flight update
- P2: use DaemonUpdateProgressStatusPayload type instead of { phase?: string }

* fix: use npm install@latest and global concurrency guard

Address Greptile review:
- npm update -g can silently exit 0 without resolving major-version
  gaps; switch to npm install -g @getpaseo/cli@latest
- Move daemonUpdateInProgress from per-session instance to module-level
  variable so concurrent clients share the same guard

* feat(daemon): harden remote self-update

Probe the global npm CLI install before updating, keep request handling in a daemon self-update session controller, and emit scoped daemon.update.progress messages.

* test(daemon): make self-update paths platform-stable

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-06-26 16:13:29 +08:00
Mohamed Boudra
9ad49fca92 Update CONTRIBUTING.md 2026-06-26 15:56:59 +08:00
Mohamed Boudra
3fbd82664d Make New Workspace an app-wide screen (#1746)
* fix(app): make new workspace global

New Workspace now opens as an app-wide screen. Host and project context can still preselect the form through query params, but the route itself no longer lives under a host.

* fix(app): simplify new workspace selection
2026-06-26 15:50:02 +08:00
Mohamed Boudra
14a91d889c docs: rewrite CONTRIBUTING with maintainer path 2026-06-26 14:22:39 +07:00
Mohamed Boudra
2c9db5279c Run Paseo from an official Docker image (#1740)
* Add Docker images and agent Docker Mods

Ship official container images that run the Paseo daemon headless. One
Dockerfile parametrized by BASE_IMAGE covers Debian 12/13, Ubuntu 22.04/24.04
and Alpine; it bundles Node 22, the npm-published server + CLI, a vendored
s6-overlay as PID 1, and a small Docker Mods loader.

Agents are chosen at runtime via DOCKER_MODS (pipe-separated mod images). Each
mod is a FROM scratch image carrying only an install hook that runs
`npm install -g <agent-cli>`; the loader pulls the layers from the registry,
extracts them, and runs the hook before the daemon starts, so any requested
agent is on PATH when Paseo probes provider availability.

- docker/base: Dockerfile, install scripts, s6 services, mods loader
- docker/mods/*: claude-code, codex, copilot, opencode, pi
- docker/docker-compose.example.yml + docker/README.md
- .github/workflows/docker.yml: multi-arch (amd64/arm64) buildx matrix,
  publishes to ghcr.io/getpaseo on version tags
- docs/docker.md + CLAUDE.md docs index row

* feat(docker): print pairing QR and link on daemon startup

Add an s6 oneshot service that waits for the daemon to listen, then runs
`paseo daemon pair` so the pairing QR code and link surface in the container
logs. Best-effort: never blocks boot, skips gracefully when relay is disabled.
Opt out with PASEO_PAIRING_QR=0.

* build(docker): add Arch image support

* ci(docker): build Arch without Buildx

* docs(docker): document paseo env contract

* feat(docker): add opt-in sudo mode

* docs(docker): link env references

* fix(docker): create agent config dirs

* fix(docker): default home to /home/paseo

* docs(docker): document agent auth setup

* docs(docker): document relay port setup

* fix(docker): install Node from tarball

* docs: add Docker quick start

* docs(docker): remove legacy home example

* docs(docker): set container hostname

* fix(docker): prepare opencode storage

* fix(docker): allow paseo login shell

* fix docker opencode permissions

* ci(docker): use Node 24 actions

* fix(docker): install bzip2 runtime tools

* fix(docker): update Pi mod package

* fix(docker): quiet default daemon logs

* fix(docker): split home from state

Docker images now keep HOME at /home/paseo and store Paseo daemon state under /home/paseo/.paseo by default.

Existing volumes can keep the old layout by setting PASEO_HOME=/home/paseo.

* fix(docker): keep mods out of paseo home

* ci(docker): skip alpine arm64 builds

* fix(docker): address review findings

* fix(docker): verify s6 overlay downloads

* fix(docker): honor custom healthcheck port

* fix(docker): fail on mod extraction errors

* feat(docker): add official container image

Ship a focused daemon image with the bundled web UI enabled and document extending it with agent CLIs.

* ci(docker): publish images only on stable releases

* fix(docker): check daemon health over HTTP

---------

Co-authored-by: Herbrant <cdavide98carnemolla@gmail.com>
2026-06-26 14:48:13 +08:00
Mohamed Boudra
28c5e55bd9 Fix desktop file uploads with extensions (#1741)
* fix(attachments): allow Markdown file uploads on desktop

Desktop file upload copied picked files through managed storage with bare picker extensions like md. Normalize those extensions at the command boundary and pass dot-prefixed extensions from the picker path.

* fix(attachments): stop enumerating generic file types

Generic file uploads should not depend on a hand-maintained non-image MIME table. Keep raster image inference for image handling and use octet-stream for other path-only uploads.

* test(attachments): cover picker extension format
2026-06-26 14:45:45 +08:00
Matteo Pietro Dazzi
862154541a feat: add MiniMax quota fetcher and brand icon (#1662)
* feat(server): add MiniMax quota fetcher

Mirror the live multi-provider quota panel (#1278) for MiniMax. Resolves the
bearer token from $MINIMAX_API_KEY or the MiniMax CLI config at
~/.mmx/config.json (api_key, oauth.access_token) and credentials at
~/.mmx/credentials.json, then queries the /v1/token_plan/remains endpoint on
the configured region (global by default, cn when region='cn'). The response
is normalized into ProviderUsage windows per model_remains entry, surfacing
the interval and weekly limits with their reset times and a danger tone when
the server reports status=2.

* feat(app): add MiniMax brand icon for usage page

The quota panel resolves the icon via resolveProviderIconName, which falls
back to a generic Bot for any provider not in BUILTIN_PROVIDER_ICON_NAMES.
Register 'minimax' as a built-in icon and vendor the official 24×24 brand
mark (single Path, currentColor) as MiniMaxIcon so the usage card stops
showing the generic robot.

* refactor(server): flatten nested ternaries in MiniMax quota fetcher

Greptile review on #1662 called out two nested ternaries in toIntervalWindow
and toWeeklyWindow. Extract a toneForStatus helper that uses an explicit
if/else chain so the same status-to-tone mapping is shared between the
interval and weekly windows, and add the missing trailing newline.

* fix: format
2026-06-26 14:14:29 +08:00
Mohamed Boudra
8c3b709794 fix(server): honor X-Forwarded-Proto so daemon web UI auto-connects behind HTTPS reverse proxy (#1739)
* fix(server): honor forwarded proto for daemon web UI

* fix(server): configure trusted daemon proxies

* docs: document daemon web UI reverse proxy setup

* docs: consolidate web UI proxy guidance
2026-06-26 13:26:50 +08:00
Mohamed Boudra
c5c2ace698 docs: document self-hosting the web UI and community projects
Documents the daemon-served web UI from #1635: enabling it, reverse
proxy, TLS, tunnels, and the auth/exposure model. Adds a community
projects page for community-built self-hosting tooling.
2026-06-26 11:35:36 +07:00
Mohamed Boudra
a49c658d1f Merge sidebar workspaces across all connected hosts (#1538)
* Merge workspaces across all hosts in the sidebar

Replace per-host sidebar sections with a single merged project list.
Projects that exist on multiple hosts (matched by projectKey) are
collapsed into one row. Host identity is surfaced via:

- A status-only footer pill showing online/offline host counts
- Hover cards with a mandatory host row on every workspace entry
- Subtitles showing the host name when a project spans >1 host
- A host filter in the grouping dropdown (not a sidebar switcher)

Order and group-mode stores migrated from per-server to global keys
with zustand persist migrations. New-workspace screen now shows a
merged project list with a host selector combobox.

Key files:
- workspace-structure.ts: merge algorithm by projectKey
- sidebar-view-store.ts / sidebar-order-store.ts: global state
- sidebar-workspace-list.tsx: context-based subtitle injection
- new-workspace-screen.tsx: host selector in footer row

* Add host filtering to the merged sidebar

The sidebar now merges workspaces from every connected host, so a single
global "active host" no longer fits. Host selection becomes explicit per
action via a host chooser, and agent history pages across all hosts at
once. Removes the active-host / active-server-id model and the
all-agents-list hook that fed it.

* Fix rebase fallout: translate archive host error, update stale workspace-key test

archiveWorkspacesOptimistically used a raw "Host is not connected" string
that the i18n completeness test forbids; route it through i18n.t with the
existing hostDisconnected key. The selectors memoization test asserted
un-prefixed workspace keys, but merged-sidebar keys are serverId-prefixed.

* Fix sidebar review fallout

* Address sidebar review followups

* Fix sidebar workspace identity migration

* Address sidebar review followups

* Handle partial host history failures

* Fix merged sidebar project removal and E2E drift

* Restore history routes and project removal shape

* Remove sidebar host context plumbing

* Tighten sidebar status and host filter state

* Keep new workspace project selection host-compatible

* Migrate sidebar view storage

* Streamline host selection flows

* Update all-host history on archive

* Fix empty project sidebar action

Code regression: the main merge dropped the empty-project New workspace child row while keeping the empty project persistence contract. Restore the row using the project host target so the sidebar stays aligned with the no-host-selection model.

* Fix Japanese shortcut translation key

Stale-base integration: main added the Japanese locale after this branch added cycleAgentMode to the English shortcut help map. Merging main exposed the missing ja key in CI typecheck and resources.test.

* Fix Playwright speech teardown race
2026-06-26 12:16:49 +08:00
Mohamed Boudra
1109e453bc Merge branch 'main' of github.com:getpaseo/paseo 2026-06-26 11:01:38 +07:00
Mohamed Boudra
4e2c06ec71 Serve the web client from the daemon (#1635)
* Serve the web client from the daemon

Keep the bundled browser UI opt-in and exclude it from desktop packaging so desktop builds do not ship a duplicate renderer.

* Escape daemon web UI bootstrap hint

* Fix bundled web UI dist path
2026-06-26 12:00:11 +08:00
Mohamed Boudra
d4cdd4d749 fix(app): let native user messages use font metrics 2026-06-26 10:50:16 +07:00
paseo-ai[bot]
3d6b4adc68 fix: update lockfile signatures and Nix hash [skip ci] 2026-06-25 17:24:07 +00:00
Mohamed Boudra
bbde200aa2 chore(release): cut 0.1.101 2026-06-26 00:19:37 +07:00
Mohamed Boudra
a19e3305ad docs(changelog): 0.1.101 2026-06-26 00:10:37 +07:00
Mohamed Boudra
4ba0ce44e5 chore(acp): update provider catalog 2026-06-25 23:52:20 +07:00
Mohamed Boudra
d5cb4421b5 fix(diagnostics): render sheet reports reliably
Diagnostic sheets now share one themed code surface. String reports render line by line so large log lines do not turn the whole report into one oversized native text surface.
2026-06-25 23:49:16 +07:00
Mohamed Boudra
26f169866f Add app diagnostic report (#1728)
* feat(diagnostics): add app diagnostic report

* fix(diagnostics): guard app diagnostic runs

* fix(diagnostics): include websocket runtime metrics

* fix(diagnostics): type websocket metric snapshot

* fix(diagnostics): include daemon shell env

* fix(loop): make stop cancellation test deterministic
2026-06-25 23:20:52 +08:00
Mohamed Boudra
bdd9419189 Speed up new Pi agent startup (#1727)
* fix(providers): avoid draft feature model guessing

* fix(providers): avoid draft command model guessing
2026-06-25 22:18:58 +08:00
Mohamed Boudra
f12c9e9cfa fix(voice): scope OpenAI voice credentials
Resolve OpenAI voice credentials and endpoints from voice-specific config before broader OpenAI fallbacks, and use the same REST STT provider for dictation and voice mode.
2026-06-25 18:41:57 +07:00
Mohamed Boudra
2692211ef9 fix(providers): prevent stale models during refresh
Provider settings sheets can remain mounted while hidden, so the old display cache could survive into a different provider. Scope the cache to the active provider identity and remount the sheet when that identity changes.
2026-06-25 18:41:57 +07:00
Mohamed Boudra
f13c496ebc Update ACP provider catalog 2026-06-25 18:41:57 +07:00
Mohamed Boudra
2cf041c4d8 Keep provider diagnostics useful when discovery is slow (#1724) 2026-06-25 19:30:19 +08:00
Mohamed Boudra
c00121273b Fix Windows daemon status output (#1725) 2026-06-25 19:17:02 +08:00
Mohamed Boudra
3b5e34fe12 Keep liveness pings responsive during slow requests (#1723) 2026-06-25 19:16:54 +08:00
Alcimério Rangel
9960772fe9 feat: add Brazilian Portuguese locale (#1653)
* Add Brazilian Portuguese locale

* Add missing pt-BR translations after main merge

* Test zh-CN translation fallback coverage
2026-06-25 17:36:04 +08:00
Mohamed Boudra
eaecd8d0a7 Prepare providers for direct Paseo tools (#1707)
* refactor(agent): decouple Paseo tools from MCP

Keep MCP as the fallback adapter while exposing the same runtime catalog for providers that can register tools directly.

* fix(protocol): keep native tool support server-internal

* fix(i18n): sync Japanese shortcut labels

* fix(agent): preserve schedule provider error
2026-06-25 13:20:51 +08:00
Mohamed Boudra
53e39e6d23 Merge branch 'main' of github.com:getpaseo/paseo 2026-06-25 12:17:15 +07:00
Mohamed Boudra
ecf4f9037e docs(release): document release previews 2026-06-25 12:14:15 +07:00
Mohamed Boudra
f9ff668a71 Link worktrees to PRs from differently named tracked branches (#1718)
* fix(server): link PRs for differently named tracked branches

PR lookup now uses the configured tracked head ref when it differs from the local branch, while keeping fork owner scoping only for cross-repo remotes.

* fix(server): avoid extra PR lookup git reads

Use the shared lookup-target builder for an early local-branch result before reading origin and base metadata on on-demand PR status refreshes.
2026-06-25 05:05:32 +00:00
Mohamed Boudra
b561b108a7 refactor(server): extract the workspace-scripts feature into a deep module (#1716)
* refactor(server): extract the workspace-scripts feature into a deep module

The service-proxy-backed "workspace scripts" feature had no home: building a
workspace's scripts payload was duplicated between the descriptor builder and
buildWorkspaceScriptPayloadSnapshot (the same 9-input buildWorkspaceScriptPayloads
assembly), and the "scripts available on this daemon?" guard appeared a third time
in the start handler.

Move the feature into session/workspace-scripts/workspace-scripts-service.ts behind
createWorkspaceScriptsService(deps): { buildSnapshot, emitStatusUpdate, start }. The
payload assembly and availability guard now live in one place; the descriptor builder,
the status-emission path, and the start RPC all funnel through it. spawnWorkspaceScript
is injected as a port so the feature is testable without a real process. session.ts
drops ~98 lines; the two methods existing tests reach stay as thin delegators.

New zero-mock unit test covers the guard, status emission, and the start branch matrix
with injected fakes, the real service proxy + runtime store, and a fake launcher.

* refactor(server): address review — durable comment + documented test stand-in

- Drop the transient "#1714" PR reference from the buildWorkspaceScriptPayloadSnapshot
  accessor comment; state the durable reason instead.
- Hoist the opaque terminalManager test stand-in to a named const documenting the
  non-call guarantee, rather than an inline `as unknown as` cast.
2026-06-25 12:20:55 +08:00
Mohamed Boudra
0be5cc9dae refactor(server): extract the agent-update subscription stream into a deep module (#1715)
Move the per-client `agent_update` subscription out of session.ts into
session/agent-updates/ behind createAgentUpdatesService(deps). The module owns
the mutable subscription state, the bootstrap buffer, the provider-visibility
gate, and the filter predicate; the rest of session.ts no longer pokes the
subscription shape or hand-rolls `agent_update` payloads — the ~12 call sites
collapse to thin delegations (forwardLiveAgent / emitStoredRecord / removeAgent /
beginSubscription / flushBootstrapped / clearSubscription / dispose) plus the
pure, shared matchesAgentUpdatesFilter used by the snapshot listing pager.

The shared payload builders (buildAgentPayload / buildStoredAgentPayload /
isProviderVisibleToClient) and buildProjectPlacementForWorkspaceId stay in
session.ts and are injected as host callbacks, since they are used widely
outside this cluster. session.ts: 6187 -> 5967.

Behavior preserved: the existing session.workspaces.test.ts integration suite is
repointed through the new boundary and stays green; the previously-untested
filter / buffer / flush branches are now covered by a zero-mock unit test with
injected fakes. cleanup() now disposes the subscription (it was never cleared
before). The clear-attention-for-workspace path that emits agent_update directly
(bypassing the buffer) is left as-is — a pre-existing inconsistency, out of scope.
2026-06-25 12:20:46 +08:00
Mohamed Boudra
e4f32a4d82 refactor(server): extract workspace git-observer into a deep module (#1714)
* refactor(server): extract workspace git-observer into a deep module

Move the workspace git-observer cluster out of session.ts into
session/workspace-git-observer/ behind createWorkspaceGitObserverService(deps).
The module owns the per-cwd watch targets and the WorkspaceGitService
subscription handles, so the registration / dedupe / branch-change / teardown
lifecycle lives in one place instead of being reached into by hand from the
emit hot-path, the archive path, CheckoutSession's host, and dispose.

session.ts: 6401 -> 6219 lines. The caller surface narrows: archive teardown
calls removeForWorkspaceId / removeForCwd (no longer resolving watch targets and
cwds itself), and dispose() replaces iterating a raw subscription Map.

Sheds dead code orphaned by 40e27f5a0 (FSWatcher watching moved into
WorkspaceGitService): the workspaceGitFetchSubscriptions Map (never written), the
WorkspaceGitWatchTarget watchers/debounceTimer/refreshPromise/refreshQueued
fields, and the no-op closeWorkspaceGitWatchTarget method.

The module is unit-tested with injected fakes (zero mocks); the existing
session.workspace-git-watch.test.ts integration suite is repointed through the
new boundary and still passes.

Behavior is preserved verbatim, including a latent quirk where syncObservers
seeds descriptor state with a directory in the workspaceId slot (an effective
no-op) -- flagged as a follow-up, not changed here.

* refactor(server): clear watch targets on dispose; document shouldSkipUpdate dedupe

Addresses Greptile review on PR #1714:
- dispose() now clears watchTargets alongside subscriptions so post-teardown
  state is consistent (dispose is terminal; behavior-preserving).
- Document shouldSkipUpdate as a check-and-record dedupe gate (mutation on the
  changed path is intentional, preserved from the original).

* test(server): make git-observer test paths platform-portable

server-tests (windows-latest) failed: the test asserted raw POSIX path literals
against the service's resolve(cwd)-normalized output, which becomes D:\repo\ws1
on Windows. Resolve the test cwds the same way so assertions hold on every
platform (matches the path.resolve pattern in session.workspace-git-watch.test).
Test-only fix; production normalization is unchanged and correct.
2026-06-25 12:20:38 +08:00
Mohamed Boudra
b625b69302 Render images from Claude Code tool results in chat (#1717)
* feat(providers): render images Claude Code emits instead of base64

Claude returns images as base64 blocks inside tool_result content; they were
dumped into the tool output as a wall of raw base64 text. Materialize them to a
temp file and emit an assistant_message markdown image — the path Codex already
uses — so the client fetches the bytes over the existing WebSocket binary-frame
channel. No protocol or client change; assistant_message is an existing type.

Also strips the base64 out of the failed-tool-call error field, and widens the
Claude history-replay filter so the image survives reload. Collapses the image
writer Codex had duplicated twice into one shared materializeProviderImage.

* fix(providers): match only materialized image paths in the history filter

The history-replay filter recognized a provider image by a leading "![". Review
flagged that user-authored text starting with an image-markdown pointing at the
attachments dir could then replay as assistant output. Tighten the predicate to
the content-hashed <sha256>.<ext> shape the writer actually emits, so only
materialized images pass.

* fix(providers): handle Windows paths in the image-markdown predicate

On Windows the materialized image path uses backslash separators, and
escapeMarkdownImageSource doubles each backslash in the markdown source. The
history-replay predicate matched a single separator, so on a Windows daemon the
image was dropped on reload; the test path assertions failed for the same
reason. Allow one-or-more separators in the predicate, unescape the source in
the test helper, and add a Windows-path case to the predicate test.
2026-06-25 04:11:46 +00:00
Mohamed Boudra
1b9543023e refactor(server): extract workspace provisioning into a deep module (#1712)
* refactor(server): extract workspace provisioning into workspace-provisioning module

Move the "find-or-create a workspace & project for a directory" cluster out of
the 6.4k-line Session god class into session/workspace-provisioning/
behind an injected createWorkspaceProvisioningService(deps) port.

Nine methods (~215 LOC) — findOrCreateWorkspaceForDirectory,
resolveOrCreateWorkspaceIdForCreateAgent, createWorkspaceForDirectory,
findOrCreateProjectForDirectory, ensureWorkspaceRecordUnarchived, and the
private helpers resolveWorkspaceDirectory / findExactWorkspaceByDirectory /
reclassifyOrUnarchiveWorkspaceForDirectory / resolveProjectRecordForPlacement —
now live in one deep module with a 5-method interface. The session shed four
pure-function imports it no longer needs and delegates from five call sites.

The module's only dependencies are the workspace/project registries and the
git-service checkout port — no emit, clientActivity, or observer coupling — so
it is now unit-testable with real file-backed registries and a fake git port
(workspace-provisioning-service.test.ts), instead of only through a booted
Session. Behavior is unchanged: the existing open_project / import / create-agent
characterization suites stay green.

* refactor(server): address review — drop redundant spread property, test always-create

- Remove the dead `workspaceId: input.workspace.workspaceId` in
  reclassifyOrUnarchiveWorkspaceForDirectory (already spread via
  ...input.workspace; behavior-identical).
- Add a direct test pinning createWorkspaceForDirectory's always-mint-fresh
  contract (call twice for the same cwd → two distinct workspace ids), the
  property that distinguishes it from findOrCreateWorkspaceForDirectory.
2026-06-25 03:36:14 +08:00
Mohamed Boudra
83123987d7 refactor(server): extract git-mutation primitives into git-mutation module (#1711)
* refactor(server): extract git-mutation primitives into git-mutation module

Move checkoutExistingBranch, createBranchFromBase and notifyGitMutation
(plus their internal ref-validation / clean-tree / branch-existence helpers)
out of the 6.5k-line Session class into
session/git-mutation/git-mutation-service.ts — a deep module with a
3-method interface and a createGitMutationService(deps) factory, mirroring
the git-metadata-generator port extracted in #1702.

These primitives were smeared across three sub-session boundaries as host
callbacks. CheckoutSession now takes gitMutation directly, shrinking
CheckoutSessionHost from 6 members to 4; the worktree session-config
builder and the auto-naming / worktree-creation paths call
this.gitMutation.* instead of private Session methods.

Adds git-mutation-service.test.ts: guard branches covered with in-memory
fakes, happy paths against a real temp git repo. The superseded
internal-mock tests in session.test.ts (which stubbed execCommand and the
git service) are removed in favour of the real-dependency coverage.

* refactor(server): address review on git-mutation-service

- Inline doesLocalBranchExist into its sole caller: once the redundant
  ref-validation is removed (createBranchFromBase already validates
  newBranchName with the "new branch" label), the helper is a pure
  passthrough to workspaceGitService.hasLocalBranch.
- Replace .some(...).toBe(true) snapshot assertions with toContainEqual so
  failures surface the full recorded call (and assert cwd too).
2026-06-25 01:01:40 +08:00
Mohamed Boudra
45a7a91768 Improve workspace names for slash-command prompts (#1709)
* fix(server): mark metadata prompts as naming input

* fix(app): add Japanese agent mode shortcut label

* fix(server): keep mock metadata prompt detection current

* test(app): retry e2e temp workspace cleanup
2026-06-25 00:00:43 +08:00
Mohamed Boudra
484ffc4334 refactor(server): extract git-metadata generators into checkout module (#1702)
The LLM-backed commit-message and PR-text generators lived in the 6.6k-line
session god-file but were consumed only by CheckoutSession, which reached them
through two host callbacks (the host comment even flagged that it "does not own
them"). The two methods were ~80% identical.

Lift them into session/checkout/git-metadata-generator.ts as a deep module with
a two-method interface, collapsing the duplicated diff -> fileList -> patch ->
prompt -> generate -> fallback scaffold. The LLM call is now an injected
StructuredTextGeneration port: production wires resolve-providers + structured
generation; the new unit test injects a fake and exercises success, both
fallback paths, and error rethrow without mocking any module.

CheckoutSession takes the generator as a typed collaborator instead of two host
callbacks; Session sheds ~160 lines plus five now-unused imports. Behaviour is
unchanged — the existing session.test.ts generation tests pass untouched.
2026-06-24 23:48:00 +08:00
sysCat64
507345dbee feat(i18n): add Japanese (ja) locale (#1694)
* feat(i18n): add Japanese (ja) locale

- Add packages/app/src/i18n/resources/ja.ts with 1287 keys matching en.ts
- Register ja in i18next.ts, locales.ts (SupportedLocale, LANGUAGE_OPTIONS,
  SUPPORTED_LANGUAGES, LANGUAGE_NATIVE_NAMES, LANGUAGE_NAMES_BY_LOCALE,
  resolveSupportedLocale)
- Add ja label key to all six existing locale files (ar, en, es, fr, ru, zh-CN)
- Update locales.test.ts to cover ja in all relevant assertions

Translation reviewed by Codex across four passes; terminology, placeholder
parity, and naturalness confirmed clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(i18n): add ja to resources.test.ts and fix test descriptions

- Import ja and add to key-parity, fallback-ratio, interpolation, and
  model-count assertions in resources.test.ts
- Update test descriptions to say "all supported language(s)" instead of
  "UN official language(s)" (ja is not a UN official language)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs: add Japanese README (README.ja.md)

Add README.ja.md translated from the English source, reviewed by Codex
across two passes. Link it in the language navigation of README.md and
README.zh-CN.md.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: apply oxfmt formatting to locales.ts and ja.ts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 13:11:35 +00:00
paseo-ai[bot]
70ea960153 fix: update lockfile signatures and Nix hash [skip ci] 2026-06-24 10:46:37 +00:00
Mohamed Boudra
119afd7281 chore(release): cut 0.1.100 2026-06-24 17:42:47 +07:00
Mohamed Boudra
e58725ee39 docs(changelog): 0.1.100 2026-06-24 17:41:17 +07:00
Mohamed Boudra
36cdfaf516 chore(acp): update provider catalog to latest registry versions 2026-06-24 17:41:13 +07:00
Mohamed Boudra
c5442ef0a2 Stop Claude context meter from doubling requests (#1701)
* fix(claude): stop probing context usage after turns

Use Claude stream/result usage for parent context tracking, and update the meter from compact boundary post tokens. Subagent usage reports stay out of the parent context meter.

* fix(claude): clear compact context fallback between turns
2026-06-24 17:44:39 +08:00
Mohamed Boudra
0748149ec9 feat(providers): expose custom agent selection (#1700) 2026-06-24 16:39:48 +08:00
Mohamed Boudra
8c67415fdb Merge branch 'main' of github.com:getpaseo/paseo 2026-06-24 14:42:26 +07:00
Mohamed Boudra
6fe320055d feat(app): cycle agent modes with Shift+Tab 2026-06-24 14:41:14 +07:00
Mohamed Boudra
2ef119c24b Fix OMP slash commands and skills loading (#1698)
* Fix OMP slash command discovery

Keep Pi on get_commands while allowing the OMP-backed provider to use get_available_commands. Both providers reject the other command without echoing an id, so the adapter needs an explicit command-list RPC setting instead of a timeout-based fallback.

* Address OMP command test review
2026-06-24 15:11:05 +08:00
Mohamed Boudra
79be6d8dba Stop OpenCode helper servers from leaking (#1697)
* Fix OpenCode helper server ownership

Own OpenCode helper generations as soon as spawn returns so startup timeout and shutdown clean up through the manager/reaper path. Collapse the OpenCode runtime wrapper so the provider talks to the server manager directly.

* Protect dedicated OpenCode server startup
2026-06-24 14:44:43 +08:00
paseo-ai[bot]
42e3f63dec fix: update lockfile signatures and Nix hash [skip ci] 2026-06-23 15:52:14 +00:00
Mohamed Boudra
7a817774b7 chore(release): cut 0.1.99 2026-06-23 22:47:51 +07:00
Mohamed Boudra
12101914c2 Refresh changelog for 0.1.99 2026-06-23 22:46:18 +07:00
Mohamed Boudra
f94b488c4f Fix ACP user message echo dedupe 2026-06-23 22:25:16 +07:00
Mohamed Boudra
9170c2f0e6 Update ACP provider catalog to latest registry versions 2026-06-23 21:53:11 +07:00
Mohamed Boudra
78d46a8a82 Restore workspace after reopening app
Native state restore can resume at the host route instead of the root index, so host home now resolves the remembered workspace itself. Keep shared route policy outside src/app so Expo Router does not register it as a page.
2026-06-23 21:09:35 +07:00
Mohamed Boudra
1970a14349 Keep provider diagnostics and model lists in sync (#1660)
* fix(server): align provider catalog diagnostics

* fix(server): preserve catalog profile models

* refactor: unify provider catalog discovery under AgentClient.fetchCatalog

Remove listModels/listModes from AgentClient and fetchModels/fetchModes
from ProviderDefinition. All provider runtime discovery now flows through
a single fetchCatalog(options) => ProviderCatalog API.

ProviderSnapshotManager.listModels/listModes remain as cached snapshot
conveniences only. Provider implementations (acp, codex, opencode, pi,
claude, mock) updated accordingly; agent-manager default model resolution
now calls fetchCatalog.

Reshape step toward issue pi-model-list-empty.

* refactor: remove remaining provider listModels/listModes runtime API residue

Migrate remaining AgentClient/provider-client implementations and tests to
fetchCatalog. Remove obsolete ListModelsOptions/ListModesOptions interfaces.
Update ProviderSnapshotManager.getProviderDiagnostic to materialize clients
via ensureClient(provider, definition) so diagnostics self-heal the settings
sheet instead of failing when providerClients[provider] is absent.

Allowed to remain: ProviderSnapshotManager.listModels/listModes as cached
snapshot readers; protocol/client legacy list_provider_models names; unrelated
local helper in create-agent-mode.

* test(server): repair test clients after fetchCatalog refactor

- Restore TestAgentClient.fetchCatalog with proper model list and resumeSession.
- Restore NativeArchiveRecordingClient and EnvProbeAgentClient removed during refactor.
- Fix ResumeCaptureClient.fetchCatalog and resumeSession.
- Fix stream-coalescing TestAgentClient.fetchCatalog shape and isAvailable.
- Mock accessible OpenCode provider in full-access mode tests so fetchCatalog does not throw.

* refactor(app): update stable discovered models ref directly during render
2026-06-23 17:55:47 +08:00
Mohamed Boudra
a397d411dd Improve GitHub panel toolbar and loading states (#1664)
* feat(app): add toolbar and loading states to the GitHub PR panel

Adds a toolbar under the PR title with a refresh button (refreshes git
and GitHub status + timeline in one tap), an always-visible View Pull
Request button, and a Merge control with a method dropdown that is
disabled-with-reason when the PR can't be merged yet.

The merge control reuses the existing git-actions policy verbatim via a
new buildPrPanelMergeActions selector fed into GitActionsSplitButton, so
no merge readiness/squash/auto-merge logic is duplicated.

Also replaces the empty-state flash with skeletons: a full-panel
skeleton while PR status loads and an activity skeleton while the
timeline loads, with empty states shown only when loaded-and-empty and
an error state with retry.

* refactor(app): tidy PR panel skeleton (i18n, shared pulse, index)

Addresses review feedback on the PR panel work:
- Localize the skeleton's "Checks" label via t() instead of hardcoding.
- Hoist the shared SkeletonPulse + useSkeletonPulse into activity-skeleton
  so pane-skeleton reuses them instead of duplicating the pulse driver.
- Export PullRequestPaneError/Skeleton from the module index and import
  them through it, matching the module's existing public surface.

* test(app): remove mock-based PR pane test

* fix(app): simplify PR panel toolbar

* fix(app): report PR panel retry failures
2026-06-23 17:50:53 +08:00
Mohamed Boudra
d9cd6ea0fd Keep composer mode preferences stable (#1658)
* fix(app): preserve composer mode preferences

Treat undefined provider preference updates as no-ops, and keep saved mode selections instead of silently defaulting them when provider metadata is incomplete or does not list the saved mode.

* fix(app): document mode preference preservation
2026-06-23 09:22:26 +00:00
Mohamed Boudra
c03e7b82b4 Keep compact file explorer visible while open (#1661)
* fix(app): keep compact explorer mounted while open

* fix(app): share compact explorer host state

* fix(app): refresh diff row metrics after font changes
2026-06-23 16:10:37 +08:00
Mohamed Boudra
26b2f25050 refactor(server): decompose session.ts into per-domain subsystems (#1646)
* refactor(server): move checkout write handlers into CheckoutSession

The checkout read side moved into session/checkout/checkout-session.ts in
#1644; the 17 inline write/PR/stash handlers stayed on the Session god-object.
This carves them into the same subsystem behind an expanded CheckoutSessionHost,
so dispatchCheckoutMessage becomes pure delegation and session.ts drops ~800
lines.

Moved into CheckoutSession (verbatim, only this.X -> this.host.X rewiring):
switch/rename branch, commit, merge, merge-from-base, pull, push, PR
create/merge, github auto-merge/check-details, PR status/timeline, github
search, stash save/pop/list, plus resolveCurrentPullRequest and the
PR-timeline helpers.

CheckoutSessionHost gains the Session-owned, non-checkout collaborators these
handlers orchestrate (notifyGitMutation, emitWorkspaceUpdateForCwd,
handleWorkspaceGitBranchSnapshot, renameCurrentBranch, checkoutExistingBranch,
and the LLM commit/PR-text generators); paseoHome/worktreesRoot join the
options bag. GitMutationRefreshReason moves to utils/checkout-git.ts so both
the shell and the subsystem share one canonical type.

session.test.ts checkout tests now drive the handlers through the public
handleMessage boundary instead of reaching into private methods; new mock-free
fake-host tests cover the moved handlers directly in checkout-session.test.ts.

Behavior-preserving: full session.test.ts + checkout-session.test.ts green,
typecheck/lint/format clean.

* refactor(server): extract chat/schedule/loop handlers into ChatScheduleLoopSession

Move the 7 chat/*, 9 schedule/*, and 5 loop/* request handlers, the three
rpc-error emitters (kept separate), and toScheduleSummary out of the Session
god-object into session/chat/chat-schedule-loop-session.ts, mirroring the shipped
CheckoutSession/VoiceSession deep-module-with-narrow-Host pattern. The least-coupled
remaining domain: stateless request/response over chatService/scheduleService/
loopService, reaching the shell only through a narrow ChatScheduleLoopSessionHost
(emit + agent-roster reads + the mention-fanout send). session.ts drops ~590 lines.

The two dispatch switches collapse into one: schedule/* was previously reached only
via the chat dispatcher's default fall-through arm — now all 21 types are explicit
delegation cases, removing that fragility.

Behavior-preserving: a new routing test drives the real Session.handleMessage for all
21 types (guards against a silently-dropped case), green before and after the move; a
collocated subsystem test covers the mention-fanout send seam, the fanout-limit error
code, the self->agent target remap, and the toScheduleSummary runs-stripping.

* refactor(server): extract provider catalog handlers into ProviderCatalogSession

Move the 8 provider-catalog handlers (model/mode/feature listing, providers
snapshot pull/refresh, diagnostic, usage) plus emitProviderDisabledResponse,
getProviderSnapshotEntryForRead, buildDraftAgentSessionConfig, the two mode-icon
downgrade helpers, and the providers_snapshot_update PUSH wiring out of session.ts
into session/provider/provider-catalog-session.ts behind a narrow
ProviderCatalogSessionHost seam — the same deep-module pattern as ChatScheduleLoopSession,
CheckoutSession, and VoiceSession.

The PUSH (start) and every PULL gate provider visibility and downgrade mode icons
through the SAME injected predicates (isProviderVisibleToClient + supportsCustomModeIcons),
both reading appVersion/clientCapabilities live — the COMPAT invariant the shell could
only enforce by code proximity before. dispatchProviderMessage collapses to delegation;
start()/dispose() wire into subscribeToOptionalManagers/cleanup at the same ordinal.

session.ts drops ~372 lines. Behavior-preservation gate (provider routing through the
real Session.handleMessage) green before and after; full session.test.ts 115/115; new
collocated subsystem test covers the PUSH/PULL parity seam, the disabled-provider path,
and the usage/feature error envelopes.

* refactor(server): extract workspace file-access handlers into WorkspaceFilesSession

Move the file-explorer, file-upload, file-transfer-frame, project-icon and
file-download-token handlers out of session.ts into
session/files/workspace-files-session.ts behind a narrow WorkspaceFilesSessionHost
seam (emit/emitBinary/hasBinaryChannel), matching the deep-module-with-Host shape
used by CheckoutSession/ChatScheduleLoopSession/ProviderCatalogSession.

The new module owns the FileUploadStore and is injected the daemon's
DownloadTokenStore; the shell's dispatch cases and the file_transfer binary route
collapse to delegation. The five handlers touch no workspace-git observer,
registry or subscription state, so this is a clean self-contained slice of the
workspace domain. session.ts drops 240 lines (7316 -> 7076).

Behavior-preservation tests (file explorer list/read/binary, download token
success + empty-cwd, project icon, upload round-trip) added to session.test.ts and
run green against the pre- and post-refactor Session; a collocated
workspace-files-session.test.ts exercises the module via a fake host with real
upload/download stores (no module mocks).

* refactor(server): extract agent-config setters into AgentConfigSession

Move the four agent-config setters (set_agent_mode/model/feature/thinking)
out of the session god-object into session/agent-config/, collapsing their
four near-identical try/log/emit envelopes into one applyConfigChange helper
behind a narrow { emit } host plus an AgentConfigOperations port.

Behavior-preserving: the dispatch cases delegate unchanged, setAgentModeCommand
still routes the mode path, and model/feature still emit no notice. Adds the
previously-absent failure-envelope coverage: 8 gate tests in session.test.ts
(success + forced failure per setter, asserting the *_response envelope and the
activity_log-before-response ordering) green before and after the carve, plus a
collocated agent-config-session.test.ts (typed fake operations + fake host, no
module mocks). session.ts drops ~173 lines.

* refactor(server): extract project-config handlers into ProjectConfigSession

Move read/write_project_config handlers, their failure emitters, and the
root-resolution helpers (resolveKnownProjectRootForConfig, canonicalizeConfigRoot,
stripTrailingPathSeparators) out of session.ts into session/project-config/, behind
a narrow { emit } host plus a projectRegistry port — matching the established
session/<domain>/ pattern. Also corrects a misfiling: project-config was dispatched
inside dispatchAgentConfigMessage despite being its own domain.

session.ts drops 155 lines (6903 -> 6748). The handleMessage-driven gate in
session.test.ts (project config RPC authorization) is green before and after; new
collocated project-config-session.test.ts covers the subsystem with a fake host and
fake registry over a real filesystem (no module mocks).

* refactor(server): extract daemon status/pairing handlers into DaemonSession

Move the two daemon.* RPC handlers (daemon.get_status, daemon.get_pairing_offer)
out of session.ts into session/daemon/daemon-session.ts behind a narrow
{ emit } host + injected reads (paseoHome, serverId, daemonVersion,
daemonRuntimeConfig, listProviderAvailability). These were misfiled inside
dispatchAgentConfigMessage despite being daemon- not agent-scoped; the slice
also removes the now-dead serverId/daemonVersion/daemonRuntimeConfig fields and
the getPidLockInfo/generateLocalPairingOffer imports from the god-file, and
collapses the inline daemonRuntimeConfig type into a named DaemonRuntimeConfig.

The daemon-config get/set handlers stay inline: daemonConfigStore is a
listener-bearing, cross-domain hub (read by structured-generation,
websocket-server, bootstrap, archive-if-safe), so moving it would double-own it.

session.ts: 6748 -> 6662 lines. Gate: new session.test.ts daemon round-trips
(status success + listing-rejects fallback, pairing relay-disabled) pass before
and after; new collocated daemon-session.test.ts (fake host + injected reads
over a real temp dir, no module mocks) 4/4. typecheck/lint/format clean.
2026-06-22 10:44:07 +08:00
Mohamed Boudra
2b5cc727f0 refactor(server): move VoiceSession into session/voice/ to match CheckoutSession (#1645)
#1640 extracted the voice subsystem to server/voice/ (a peer of session.ts);
#1644 extracted the checkout read side to session/checkout/. The two carves
landed in inconsistent homes, so there was no single place to find "what has
session.ts been carved into."

Relocate server/voice/ -> server/session/voice/ so all session subsystems share
one home alongside session/checkout/. Pure relocation: git-tracked renames with
import-depth bumped one level (../ -> ../../); the only external touch point is
the import path in session.ts. Behavior preserved verbatim — the 17 voice unit
tests pass unchanged before and after.

Also record the actual shipped layout and the next carve (the checkout mutation
handlers still inline in session.ts -> session/checkout/checkout-session.ts) in
the decomposition plan, and fix its now-stale voice path.
2026-06-21 20:45:26 +08:00
Mohamed Boudra
18f880e561 refactor(server): extract checkout read subsystem into CheckoutSession (#1644)
Extract the checkout read & live-stream side (status, branch validate/suggest, manual refresh, diff + status subscriptions) out of session.ts into session/checkout/checkout-session.ts behind a narrow CheckoutSessionHost seam and a CheckoutDiffSubscriber port. session.ts -234 net lines; 12 unit tests via injected fakes (no module mocks); 6 session tests moved onto the public handleMessage path. Also fixes a pre-existing tilde-resolution bug in the refresh handler (TDD).
2026-06-21 19:37:48 +08:00
Mohamed Boudra
cf4dd8616c refactor(app): move workspace setup-status fetch into the setup store (#1641)
* refactor(app): move workspace setup-status fetch into the setup store

The workspace screen ran a fetch-once-and-store workflow inline in a
useEffect, deduped by a hidden requestedWorkspaceSetupStatusKeyRef state
machine and a manual cancellation flag. The fetch + dedup + response
validation only existed inside the component, so it could only be
exercised through E2E.

Move the workflow into useWorkspaceSetupStore as an idempotent
ensureSetupStatus action with the daemon client injected as a port. The
store owns the in-flight dedup (requestedKeys) and clears the marker on
error/removal so a later attempt retries. The component effect now just
delegates; the ref and cancellation flag are gone.

This makes the workflow unit-testable with a fake client (no mocks) and
is the first slice of pulling business logic out of the workspace-screen
god component.

* fix(app): release the setup-status in-flight marker on every settle

Greptile P1: when fetchWorkspaceSetupStatus returned a null snapshot or a
mismatched workspaceId, the key stayed in requestedKeys forever, so a later
mount could never retry — a regression versus the old component-scoped ref,
which reset on remount.

Make requestedKeys a pure in-flight marker: add it before the fetch and
release it in a finally once the request settles (success, ignored, or
error). A settle that stored no snapshot leaves no marker, so the next call
retries; once a snapshot lands, the snapshots[key] guard prevents redundant
refetches. This also lets removeWorkspace/clearServer drop their now-redundant
requestedKeys pruning.

Adds tests for retry-after-null-snapshot and retry-after-mismatched-workspace.
2026-06-21 16:40:13 +08:00
Mohamed Boudra
617cf8a7bf refactor(server): extract voice mode subsystem into VoiceSession (#1640)
* refactor(server): extract voice mode subsystem into VoiceSession

session.ts was a 10.5k-line god object and the most-churned file in the
repo. ~1,100 of those lines were an entire voice/audio subsystem — the
STT/TTS/dictation managers, the barge-in audio-buffering state machine,
voice-turn orchestration, and the MCP voice bridge — interleaved field by
field and method by method with workspace/git/agent/provider concerns.

Move the whole subsystem into a new VoiceSession deep module. Session now
holds one `voiceSession` field, constructs it once, and delegates the
voice/dictation/abort message types, the permission auto-allow gate, and
cleanup to it. VoiceSession owns all 18 voice fields and ~25 methods and
reaches back into the agent run only through a narrow VoiceSessionHost
seam (emit, loadAgent, reloadAgentSession, sendSpokenInput,
interruptAgentIfRunning, hasActiveAgentRun).

Behavior is preserved (verbatim method moves); deleting voice-session.ts
now removes the feature mechanically. The voice unit tests drive the
VoiceSession boundary instead of reaching into Session internals, so
future voice tests can construct a VoiceSession with a fake host.

session.ts: 10,468 -> 9,272 lines.

* refactor(server): move VoiceSession into voice/ and test it at the boundary

Address Greptile review on #1640:

- Move voice-session.ts into the existing voice/ subdirectory, alongside
  voice-turn-controller.ts, instead of adding another peer to the 30+ file
  server/ directory. The directory now carries the domain.
- Add voice/voice-session.test.ts beside the module: it constructs a
  VoiceSession with a fake VoiceSessionHost and drives it through the public
  API (handleSetVoiceMode, then turn-detection/STT events), asserting on the
  host seam (sendSpokenInput) and emitted messages. No Session, no private
  field/method access.
- Remove the voice tests from session.test.ts that reached through Session's
  private voiceSession field into VoiceSession internals.

Same three behaviors are covered (streaming final -> agent, finalization
timeout empty path, low-confidence filtering); driving handleSetVoiceMode
additionally exercises the enable path the old tests bypassed.
2026-06-21 07:14:23 +00:00
Mohamed Boudra
9c86a410ea fix(website): support stable AppImage asset names 2026-06-21 01:14:00 +07:00
Mohamed Boudra
2d8acc1611 docs: add troubleshooting page
Covers providers showing "Not installed", the login-shell/PATH
mismatch behind agents and terminals not seeing your commands, and
restarting the daemon after config changes.
2026-06-21 00:52:29 +07:00
paseo-ai[bot]
f2e7ac2dc1 fix: update lockfile signatures and Nix hash [skip ci] 2026-06-20 17:34:11 +00:00
Mohamed Boudra
fbd86564dd chore(release): cut 0.1.98 2026-06-21 00:30:25 +07:00
Mohamed Boudra
c38510d347 chore: refresh package-lock for release 2026-06-21 00:19:30 +07:00
Mohamed Boudra
ab433aa110 Refresh changelog for 0.1.98 2026-06-21 00:16:09 +07:00
Mohamed Boudra
ecb74bc8fb Update ACP provider catalog to latest registry versions 2026-06-20 23:25:00 +07:00
Mohamed Boudra
02838ca8bc Stub unarchiveSnapshot in stale AgentManager test doubles
Commit 25252d1b8 added AgentManager.unarchiveSnapshot, now called from
the shared unarchiveAgentState helper. The hand-rolled manager doubles in
the session-workspaces and import-sessions suites were not updated, so the
refresh/import RPC paths hit an unstubbed method and failed. Add the
method to those doubles; no production change.
2026-06-20 23:24:03 +07:00
Mohamed Boudra
a924059daf Show live context usage during active turns 2026-06-20 23:20:10 +07:00
Mohamed Boudra
1927dbb190 Revert "Forward live usage stream events"
This reverts commit 4779757138.
2026-06-20 22:52:34 +07:00
Mohamed Boudra
4779757138 Forward live usage stream events
The wire schema already allowed completed-turn usage, but rejected active usage updates before clients could see them. Accept usage_updated payloads and preserve turn ids on streamed turn events.
2026-06-20 22:47:49 +07:00
Mohamed Boudra
5180708e26 Merge branch 'main' of github.com:getpaseo/paseo 2026-06-20 22:29:28 +07:00
Mohamed Boudra
7f74853174 Keep collapsed projects across sidebar refreshes
Sidebar project lists can be partial while hosts or workspace data are changing. Preserve collapsed preferences instead of treating a temporarily missing project as a request to expand it.
2026-06-20 22:26:17 +07:00
Mohamed Boudra
c2d7796b78 Isolate Kimi usage home lookup in tests (#1636) 2026-06-20 22:54:41 +08:00
Mohamed Boudra
25252d1b86 Fix archived agent reload failures
Unarchive provider storage before clearing Paseo's archived flag so failed native restores keep the snapshot archived. Keep initial history sync errors visible instead of falling back to the loading state.
2026-06-20 21:33:25 +07:00
Mohamed Boudra
27ecb3a7a9 Merge branch 'main' of github.com:getpaseo/paseo 2026-06-20 21:30:07 +07:00
Mohamed Boudra
fd2fed03a6 Fix subagent context usage reporting
Task-level usage reports processed work, not the parent session's active context. Prefer current context data and parent stream usage before falling back to result totals.
2026-06-20 21:08:50 +07:00
Mohamed Boudra
4b45bab7e3 Persist live agent mode preferences 2026-06-20 21:05:52 +07:00
Mohamed Boudra
b0e6dbceef Clean up owned helper processes on daemon startup (#1632)
Record provider-owned helper processes (currently the OpenCode `serve` helper) in a daemon ledger and reconcile it in the background on startup: terminate validated leftovers, drop dead or PID-reused records without killing anything, and keep a record whose process can't be inspected for the next reconcile. Termination stops as soon as the process exits instead of always escalating to SIGKILL.
2026-06-20 22:05:12 +08:00
Mohamed Boudra
3493e0492d Polish context window usage card and meter
Calm the usage popover and settings card: smaller, muted provider logo
and name, more space between the header and the usage bars.

The popover divider used the `border` token, which equals the popover
background in dark themes and was invisible. Switch it to `borderAccent`
(the token the popover outlines itself with) and span it edge to edge.

Shrink the context meter ring and, while a session is active but usage
hasn't arrived yet, render a track-only ring in the final footprint so
the real ring fades in without shifting siblings. Agents that never
report usage still render nothing.
2026-06-20 20:19:49 +07:00
Mohamed Boudra
8bd6c617a3 Merge branch 'main' of github.com:getpaseo/paseo 2026-06-20 19:45:25 +07:00
Mohamed Boudra
68bbc75df1 Add workspace title display preference 2026-06-20 19:44:30 +07:00
Mohamed Boudra
0718e7c914 Support split worktree agent launches
Expose the workspace id from worktree creation and let agent creation attach to an existing workspace, so callers can split worktree setup from agent launch without reusing the caller workspace.

Keep worktreeSlug as the worktree path slug and add branchName for branch-off targets.
2026-06-20 19:38:57 +07:00
Mohamed Boudra
b3661193af Keep projects visible after archiving workspaces (#1631) 2026-06-20 20:20:30 +08:00
Mohamed Boudra
2c4e443ad7 Fix exact workspace file suggestions
Resolve exact suffix path queries before falling back to broad search, and keep broad hidden-directory traversal limited to known workspace config dirs.
2026-06-20 19:13:25 +07:00
Mohamed Boudra
6a3856b639 Fix Kimi usage credential lookup 2026-06-20 19:10:40 +07:00
Mohamed Boudra
80fc11541f Clean up MCP agent orchestration inputs
Require relationship and workspace on create_agent, and reuse the same worktree target union for create_worktree. Agent-scoped prompt follow-ups now default to background finish notifications so callers can continue without polling.
2026-06-20 16:59:09 +07:00
Mohamed Boudra
b8bf2345fd Make login shell environment failures visible
Log the desktop login-shell env start, applied, and failed outcomes while keeping stdout redacted to marker and length diagnostics.
2026-06-20 16:59:09 +07:00
Mohamed Boudra
4354ad3e27 Fix Playwright workspace isolation (#1627)
* Fix Playwright workspace isolation

* Clean up empty project E2E cleanup

* Clean up worktree restore E2E projects
2026-06-20 08:59:21 +00:00
Mohamed Boudra
ba8fe261ee fix(server): set opencode serve cwd to home dir to stop full-filesystem scan (#1626)
When the Paseo daemon is launched as a macOS GUI app (via launchd), its
working directory is `/`. The `opencode serve` process inherited that cwd,
causing opencode's fff_search to scan and watch the entire filesystem,
leading to 300%+ CPU and 1GB+ RAM usage.

Fix: pass `cwd: os.homedir()` when spawning `opencode serve` so it never
inherits `/`. The per-session working directory is unaffected — it is
passed separately via the OpenCode SDK's `session.create({ directory })`.

Fixes #1617
2026-06-20 15:21:40 +08:00
Mohamed Boudra
4534754617 Add projects without creating workspaces
Keep the legacy open-project request creating a workspace for old clients; new clients now add projects and create workspaces through separate flows.
2026-06-20 11:56:28 +07:00
paseo-ai[bot]
d1481833e6 fix: update lockfile signatures and Nix hash [skip ci] 2026-06-20 04:23:06 +00:00
Aditya Borakati
acea7f3d24 feat: live multi-provider quota panel (#1278)
* feat: live provider quota panel (Claude + Codex)

Adds Claude and Codex plan usage to the context window percentage circle tooltip, querying their respective APIs directly using existing CLI auth tokens.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: address review feedback - restore quota trigger, guard NaN date

- Re-add triggerFetch() on agent idle/error in agent status subscription
- Guard formatResetsAtLabel against NaN from malformed API date strings

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: resolve composer conflict and stub subscribe in websocket tests

* feat(quota): add cursor, copilot, zai, grok, and kimi quota UI and improve type safety

* feat(quota): make Claude and Codex pluggable, and map additional quota/credits metrics

* security(quota): fix shell injection vulnerability by migrating exec to execFile

* revert: restore original scripts/dev.ps1 and packages/desktop/scripts/dev.ps1

* Fix i18n resource test expectation

* Fix quota tooltip empty-provider state

* Preserve zero values in Grok quota

* Fix empty quota fetch state

* Ignore quota frames in relay reconnect tests

* Persist refreshed Codex tokens to source auth file

* fix(quota): read Claude OAuth credentials from the macOS login Keychain

On macOS, Claude Code stores its OAuth credential in the login Keychain
(generic-password item, service "Claude Code-credentials"), not in
~/.claude/.credentials.json — that file usually does not exist there, so the
Claude provider's existsSync check failed and macOS users silently got no
Claude quota. Read it via the macOS `security` CLI (its ACL only trusts
/usr/bin/security to decrypt; a native Keychain read would prompt), and stay
read-only there since Claude Code owns the refresh/persist. Linux and Windows
keep using ~/.claude/.credentials.json unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Fix quota keychain timeout and local host reconciliation

* Fix terminal notification test quota stub

* Ignore quota frames in terminal notification tests

* fix(quota): bypass Claude token refresh on macOS Keychain-backed logins

* fix(dev): update local dev daemon port to 6768 on Windows scripts/dev.ps1

* fix(dev): bypass Unix dev-daemon.sh on Windows in dev.ps1

* fix(client): handle and dispatch provider_quota event in DaemonClient

* fix(desktop): fix PowerShell quote stripping in desktop dev.ps1 config seeding

* fix(desktop): execute config update via temp file to avoid PowerShell quoting bugs

* fix(desktop): fix concurrently command invocation on Windows in dev.ps1

* fix(desktop): resolve path escaping and environment setting syntax errors in dev.ps1 for Windows

* Add on-demand provider usage views

* Fix Cursor usage billing dates

* Move provider usage renderer coverage to e2e

* Use provider manifest for quota fetchers

* Add provider usage fetch timeouts

* Reshape provider usage fetchers

---------

Co-authored-by: ABorakati <ABorakati@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
Co-authored-by: lumingjun <lumingjun@bytedance.com>
2026-06-20 12:19:50 +08:00
Mohamed Boudra
2b0740ff84 Add Claude Ultracode with setting-change notices (#1625)
* Add Claude Ultracode mode notices

* Share provider notice definitions

* Test Claude Ultracode through public turn API
2026-06-20 04:15:04 +00:00
Mohamed Boudra
7af92120fe Detach subagents without archiving them (#1612)
* Add detach action for subagents

* Translate detached subagent connection error

* Address subagent detach review feedback

* Fix subagent integration test import

* Focus detached subagents as tabs
2026-06-19 17:19:05 +08:00
Mohamed Boudra
cda66ae5f3 Make provider diagnostics easier to share (#1611)
* Show PATH matches in provider diagnostics

* Add copyable provider command probes

* Fix provider diagnostic path reporting

* Clean up command probe diagnostics
2026-06-19 12:46:39 +08:00
Mohamed Boudra
f9660c7e89 Clarify PR merge action labels (#1608)
* fix: disambiguate merge PR action labels (squash/merge/rebase)

* Clarify PR merge method labels

* Address merge label review feedback

---------

Co-authored-by: Matt Cowger <matt@cowger.us>
2026-06-19 12:04:52 +08:00
Mohamed Boudra
e73b1c4724 Fix hidden workspace file suggestions (#1609) 2026-06-19 11:56:22 +08:00
Matt Cowger
ccb8714a71 fix(opencode): trust discovered modes, don't inject hardcoded defaults (#1366)
* fix(opencode): trust discovered modes instead of always injecting defaults

When OpenCode reports its available agents, respect that list exactly
rather than seeding from DEFAULT_MODES first. This means users who have
intentionally disabled modes (e.g. Plan) in their OpenCode config will
no longer see those modes surfaced in Paseo.

DEFAULT_MODES is still used as a fallback when discovery returns nothing
(e.g. OpenCode unreachable or no agents configured).

* test(opencode): update mode discovery expectations

* test(opencode): expect discovered custom modes only
2026-06-19 11:20:26 +08:00
Matt Cowger
b90baaff71 fix: daemon warns instead of crashing on missing OpenAI speech credentials (#1368)
* fix: daemon warns instead of crashing on missing OpenAI speech credentials

validateOpenAiCredentialRequirements was throwing an error when
OpenAI speech providers were configured without credentials,
causing the daemon to crash before it could start serving health
checks. Changed to log a warning instead — the daemon starts
successfully and reports speech services as unavailable.

Closes #1367

* chore: address Greptile review feedback

- Remove duplicate warning in initializeOpenAiSpeechServices when
  all credentials are missing (already warned by validate)
- Strengthen test assertions: check getListenTarget() after start
- Wrap daemon start/stop in try/finally for proper cleanup on failure
2026-06-19 11:09:39 +08:00
paseo-ai[bot]
d0189f3f65 fix: update lockfile signatures and Nix hash [skip ci] 2026-06-18 22:51:53 +00:00
Mohamed Boudra
60cf566b79 chore(release): cut 0.1.97 2026-06-19 05:47:43 +07:00
Mohamed Boudra
de5307dfa0 Retry browser test dependency installs 2026-06-19 05:11:52 +07:00
Mohamed Boudra
0c6f008115 Update ACP provider catalog 2026-06-19 05:01:44 +07:00
Mohamed Boudra
1fc565118b Skip Playwright system deps in CI 2026-06-19 04:31:32 +07:00
Mohamed Boudra
112f1ace65 Fix legacy workspace compatibility 2026-06-19 04:14:12 +07:00
Mohamed Boudra
ee3a7a2d28 Bound Playwright browser install in CI 2026-06-19 03:26:33 +07:00
Mohamed Boudra
aff5fe651c Update ACP provider catalog 2026-06-19 01:31:31 +07:00
Mohamed Boudra
05e8d34613 Refresh changelog for 0.1.97 2026-06-19 00:50:17 +07:00
Mohamed Boudra
5d0fbd1d37 Merge branch 'main' of github.com:getpaseo/paseo 2026-06-19 00:48:23 +07:00
Mohamed Boudra
9a09ccb3d6 Fix sidebar project removal
Remove projects through a daemon RPC so the sidebar can delete the project
record after archiving its active workspaces. Reopening a removed directory
recreates the missing project record so local projects keep their derived
basename, and History skips orphaned archived workspace rows.

Fixes #1583.
2026-06-19 00:46:40 +07:00
Myriad-Dreamin
8cc74fb75d Document filename:line:column file link parsing (#1560)
* Reapply app file-open changes on v0.1.87

* fix: resolve relative assistant file links in workspace

* fix: tighten file link and open intent parsing

* Remove line and column from editor open targets

* fix: drop column offsets from file links
2026-06-18 17:27:47 +00:00
V1rus
01a2afa7c5 Fix markdown rendering hang on unmatched backticks (#1585)
* fix: prevent infinite loop on unmatched backtick runs in getInlineCodeRanges

* test: cover unmatched inline backtick runs

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-06-19 01:04:28 +08:00
Christoph Leiter
931ea97942 fix(app): reveal workspace-jump numbers only for the active modifier (#1580)
Holding the workspace-jump modifier reveals numbered badges in the sidebar for jumping to a workspace (Cmd+1..9). On the desktop app the badges also appeared while holding Option or Control, neither of which performs the jump — only Cmd does on macOS desktop (Ctrl on non-Mac, Alt on web).

The keydown/keyup handler flagged badge visibility for Alt on every runtime and for both Meta and Control on desktop — broader than the active workspace.navigate.index binding. Gate the badges to the single modifier that performs the jump on the current runtime, via a new getWorkspaceIndexJumpModifierKey helper. Adds unit tests for the three runtime cases.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 16:55:38 +00:00
Mohamed Boudra
f876b80f7a Update changelog for 0.1.97 2026-06-18 23:02:52 +07:00
Mohamed Boudra
4f78d4f679 Update ACP provider catalog 2026-06-18 23:02:36 +07:00
Mohamed Boudra
2064e308a7 Update server tests for current workspace behavior 2026-06-18 23:02:27 +07:00
Mohamed Boudra
c3321ea8ff Show PR numbers without the # and make header buttons icon-only
PR badges in the sidebar rows, hover card, and right-sidebar PR tab now
show the number alone. The Scripts and Open-in-editor header buttons drop
their labels and gain a min height so they keep the same size icon-only.
2026-06-18 22:50:40 +07:00
Mohamed Boudra
3d5059ab05 Keep legacy daemon agents attached to workspaces
Old daemons can report agents by cwd without workspace IDs. Build path-backed workspace rows for that compatibility path and reapply ownership to legacy snapshots so tab reconciliation keeps those agents visible.
2026-06-18 22:41:48 +07:00
Mohamed Boudra
ef87b09146 Update ACP provider catalog to latest registry versions 2026-06-18 20:57:27 +07:00
Mohamed Boudra
d89affb5dc Show workspace names in History titles 2026-06-18 20:52:59 +07:00
Mohamed Boudra
00c57617d5 Fix History restore and diff truncation edge cases 2026-06-18 20:44:57 +07:00
Mohamed Boudra
b3f81c0bf4 Recreate deleted worktrees from their branch on restore
Archiving a Paseo-owned worktree deletes its directory, so reopening its
agent previously dead-ended even after the workspace record came back —
there was no folder to return to.

Restore now recreates the worktree at its original path from the kept
branch (the branch is never deleted on archive), re-points the same
workspace record, and clears archivedAt — preserving the workspaceId so
the agent stays linked. The directory is recreated before the un-archive
so the reconciler can't re-archive it for a missing directory. A deleted
or already-checked-out branch surfaces a typed failure and leaves the
workspace archived.

Gated behind a worktreeRestore capability flag; older hosts get an
explicit "update your host" state instead of a misleading failure.
2026-06-18 20:44:57 +07:00
Mohamed Boudra
31fcde2ea6 Restore an archived workspace when its agent is reopened
Reopening an archived agent previously dead-ended at "workspace not
found": the agent record was un-archived but its workspace stayed
archived and was never sent to the client.

The daemon now un-archives the owning workspace alongside the agent when
its backing directory still exists, and the app auto-fires the restore
ahead of the missing-workspace gate so the user lands back in the
workspace rather than a dead end.

Worktrees whose directory was deleted on archive are not yet restorable
(recreating them from their branch is a follow-up); the workspace record
now persists its base branch as groundwork for that.
2026-06-18 20:44:56 +07:00
Mohamed Boudra
fe15b68e06 Fix per-file diff size gating 2026-06-18 20:44:56 +07:00
Mohamed Boudra
ea2f768d3f Keep connections alive through brief daemon slowdowns (#1581)
* Keep connections alive through brief daemon slowdowns

The active connection's liveness check was a side effect of the
candidate-probe loop: a single 5s latency measurement doubled as the
death timeout, so a daemon that was alive but briefly stalled (e.g. a
busy event loop) was misread as dead and the connection was torn down.

Give DaemonClient its own self-scheduling heartbeat that solely owns
liveness (15s timeout, 2 consecutive misses), and make the probe
read-only on the active connection — it reads the heartbeat's last RTT
instead of pinging. Split the shared ping into a pure measurement path
(measureLatency) and a private liveness path, so nothing outside the
client can drive teardown.

* Don't let a candidate latency timeout count as a liveness failure

The heartbeat (livenessPing) and candidate ranking (measureLatency) share
one in-flight ping slot. If a heartbeat tick lands while a candidate
measurement is still in flight, the heartbeat dedupes onto it — and a
measurement timeout was then recorded as a liveness failure, nudging the
connection one step closer to a spurious teardown.

Tag each ping with whether its timeout may drive a liveness failure
(only the heartbeat sets it) and gate recordLivenessFailure on that flag,
so an adopted measurement probe can never contribute to teardown.
2026-06-18 21:10:08 +08:00
Mohamed Boudra
eb628765d7 Merge branch 'main' of github.com:getpaseo/paseo 2026-06-18 14:08:33 +07:00
paseo-ai[bot]
61bfc5b631 fix: update lockfile signatures and Nix hash [skip ci] 2026-06-18 07:05:54 +00:00
Mohamed Boudra
59299ac635 Merge branch 'main' of github.com:getpaseo/paseo 2026-06-18 14:03:21 +07:00
Mohamed Boudra
df18bbc43a Migrate monorepo to Zod 4 2026-06-18 15:02:42 +08:00
Mohamed Boudra
1250f5cf4f Keep new-workspace composer expanded after submit
The new-workspace composer uses preserve-and-lock: it keeps the typed
text through workspace creation so it survives a failure. But the input
minimized its height on every submit, collapsing a multi-line draft to
one line while the text and loading spinner remained — clipped and ugly.

Gate the height-minimize on the same condition that clears the text, so
the box only collapses when the text actually goes away (agent panel),
not when it's preserved (new-workspace).
2026-06-18 13:41:22 +07:00
Mohamed Boudra
de84c8f179 Align sidebar header rows and tighten button spacing 2026-06-18 13:23:41 +07:00
Mohamed Boudra
7c1870e887 Merge branch 'main' of github.com:getpaseo/paseo 2026-06-18 13:04:07 +07:00
Mohamed Boudra
c8717e0c70 Regenerate terminal webview bundle
Picks up the snapshot reflow-after-resize fix from 4cc72a84e, whose
source change to renderTerminalSnapshotToAnsi (scrollback/grid wrap
flags) was never rebuilt into this generated bundle. Pure regeneration
via build-terminal-webview-html.mjs — no hand edits. The esbuild
module-path comment churn is cosmetic, from workspace dependency
resolution paths.
2026-06-18 13:00:05 +07:00
Mohamed Boudra
3163c7d09b Simplify sidebar workspace status indicator
Hide the kind icon entirely when a workspace is idle, and give the
attention state a standalone dot instead of overlaying it on the icon.
Restructure the row into an icon column plus a content column so the
subtitle and PR badge align under the title without hardcoded indents.
2026-06-18 12:56:18 +07:00
Mohamed Boudra
4e899c644a Keep keyboard offsets tied to visibility
Derive app keyboard offsets from one provider-owned shared value so chat content, composer surfaces, and floating panels share the same policy.

Treat progress-zero keyboard samples as closed, even if Android reports a stale nonzero height.
2026-06-18 12:03:44 +07:00
paseo-ai[bot]
afe7eb876c fix: update lockfile signatures and Nix hash [skip ci] 2026-06-18 04:44:09 +00:00
Mohamed Boudra
e4da3693e9 chore(release): cut 0.1.97-beta.3 2026-06-18 11:40:27 +07:00
Mohamed Boudra
a51c39eddf chore: changelog for 0.1.97-beta.3 2026-06-18 11:38:53 +07:00
Mohamed Boudra
b7ab1a1732 Update ACP provider catalog to latest registry versions 2026-06-18 11:37:35 +07:00
Mohamed Boudra
35c2e5de8e Merge branch 'main' of github.com:getpaseo/paseo 2026-06-18 11:29:29 +07:00
Mohamed Boudra
6c0b5c7640 Fix provider snapshot warm reads 2026-06-18 11:26:27 +07:00
paseo-ai[bot]
127b138a91 fix: update lockfile signatures and Nix hash [skip ci] 2026-06-17 10:19:27 +00:00
Mohamed Boudra
6fc52d85cb chore(release): cut 0.1.97-beta.2 2026-06-17 17:15:23 +07:00
Mohamed Boudra
608a950155 chore: changelog for 0.1.97-beta.2 2026-06-17 17:13:53 +07:00
Mohamed Boudra
fc4c2aa367 Decouple metadata prompt contract from overridable style
Metadata prompts (workspace title + branch, commit message, PR) mixed the
functional contract with style guidance in one block, and project-owner
instructions from paseo.json were appended after the defaults with an
"override where they conflict" notice — so custom wording competed with the
built-in style instead of replacing it.

Each prompt is now a non-overridable contract (what to produce, the JSON
shape, correctness/safety rules) plus style slots that paseo.json
instructions replace wholesale. Title and branch style are now separate
keys (metadataGeneration.title vs .branchName); previously branchName
instructions also shaped the title.
2026-06-17 16:48:24 +07:00
Mohamed Boudra
2bc48993a5 Generate shorter, task-shaped workspace titles
Auto-generated titles were full sentences that overflowed the sidebar.
The titling prompt now targets ~4 words, drops generic "do" verbs (Fix,
Add, Diagnose...), and keeps only specific operation verbs, with good and
bad examples.
2026-06-17 15:54:51 +07:00
Mohamed Boudra
e58de8c338 Use the history icon for the sidebar Sessions button 2026-06-17 15:39:52 +07:00
Mohamed Boudra
4a6503f177 Fix New Workspace keyboard positioning 2026-06-17 15:32:55 +07:00
Mohamed Boudra
90d386b88e Restore changelog entries for betas (#1568)
* Restore changelog entries for betas

Beta users check release notes, so each beta now updates an in-place
CHANGELOG.md entry that promotion overwrites with the final stable entry
— no -beta.N heading is left behind. The release machinery already
parses beta headings and syncs them; this restores the policy in the
docs and the release-beta skill, reversing the docs side of 28415b354.

* Spell out in-place beta overwrite in the promotion checklist

The stable checklist said only "update CHANGELOG.md", so a promotion
followed by the letter could stack a fresh entry on top of the
-beta.N block — the stale heading the policy forbids. Make the
promotion path overwrite-in-place explicit, matching the beta checklist.
2026-06-17 15:35:25 +08:00
Mohamed Boudra
0c9802b22b Show prompt titles immediately for new workspaces (#1567)
* Set workspace prompt titles at creation

* Cover existing workspace prompt titles

* Fix rewind E2E prompt selector
2026-06-17 15:30:32 +08:00
Mohamed Boudra
1340a80144 Fix New Workspace PR worktree creation (#1565)
* Fix PR worktree creation from New Workspace

* Use fixture default branch in GitHub E2E clone
2026-06-17 15:15:58 +08:00
xpufx
11abbcc630 fix(desktop): stop AppImage updates hanging on quit and deleting the app (#1485)
* fix(desktop): disable auto-install on quit for AppImage only

electron-updater's AppImageUpdater.doInstall() uses execFileSync with APPIMAGE_EXIT_AFTER_INSTALL when autoInstallOnAppQuit is true. For AppImages, this blocks the old process indefinitely because the new process has no installer step to exit from — the mv already completed the install.

Scoped to AppImage only (process.platform === 'linux' && process.env.APPIMAGE). .deb/.rpm/Windows/macOS keep auto-install-on-quit working as before.

* fix(desktop): keep AppImage filename stable across updates

electron-updater renames a versioned AppImage to a new path on update and
unlinks the old one. That orphaned the previous binary, broke desktop
shortcuts, and dangled the ~/.local/bin/paseo CLI symlink, which points at
$APPIMAGE. Dropping ${version} from the AppImage artifactName makes the
updater overwrite the file in place instead; deb/rpm keep versioned names.

Also documents why AppImage must not auto-install on quit (the blocking
execFileSync gated on APPIMAGE_EXIT_AFTER_INSTALL, honored only by
AppImageLauncher) and extracts that gate into a unit-tested helper.

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-06-17 07:09:35 +00:00
Mohamed Boudra
14743fa0b0 Update Agent MCP redaction smoke test (#1566) 2026-06-17 14:14:14 +08:00
Mohamed Boudra
a61118776a Shrink the Workspaces sidebar label to xs 2026-06-17 11:30:05 +07:00
Mohamed Boudra
7e5d7f7c67 Remember the New Workspace isolation choice
The local/worktree choice was unstored component state and reset on every
remount of the New Workspace screen (e.g. after creating a workspace).
Persist it in the create-form preferences (FormPreferences.isolation).

Also rename the app-layer "backing" to "isolation" to match its UI label.
Isolation is the create-time intent; it stays distinct from workspaceKind,
the git-derived persisted property, and the wire's source.kind.
2026-06-17 09:53:22 +07:00
Mohamed Boudra
69741e0770 Make the agent MCP server stateless to fix session leak
The agent-facing MCP server (/mcp/agents) ran in stateful session mode,
keeping a StreamableHTTPServerTransport plus its full Server and Zod tool
schemas alive per agent connection, keyed by session id. The map was only
pruned on the SDK's onclose/onsessionclosed callbacks, which fire only on a
clean DELETE. Agents that are killed or exit without one never get reaped, so
dead sessions accumulate for the life of the daemon (observed ~77MB across 132
stale transports in a heap snapshot).

Listing and calling tools needs no cross-request state, so each request now
builds a fresh server + transport and tears both down when the response closes.
Per-request cost is ~0.6-0.9ms and ~760KB of short-lived allocation; nothing is
retained between requests. GET and DELETE return 405, which the MCP client
tolerates (it never receives a session id).
2026-06-17 09:18:29 +07:00
paseo-ai[bot]
7b4b27d877 fix: update lockfile signatures and Nix hash [skip ci] 2026-06-17 01:28:24 +00:00
Mohamed Boudra
8c509017ff chore(release): cut 0.1.97-beta.1 2026-06-17 08:24:25 +07:00
Mohamed Boudra
c32b80a2ed Update ACP provider catalog to latest registry versions 2026-06-17 08:21:34 +07:00
Mohamed Boudra
fcb7f8e215 Name agents by their first prompt line instead of an LLM summary (#1563)
* Name agents by their first prompt line instead of an LLM summary

The workspace title already captures the task; the LLM-generated agent
title just restated the same prompt. Drop the generator and keep the
provisional title (first prompt line), so the agent label stays
deterministic and explicit titles still win.

* Remove the no-op agent-title setting and leftover generator plumbing

Deleting the agent-title generator left its whole surface dangling: a
configurable "Agent titles" project setting that no longer did anything,
dead create-path plumbing, generator-only import/MCP deps, and the mock
provider's agent-title parser. Remove all of it, plus a dead test mock
and timing sleeps that guarded the deleted background job. Replace them
with a no-mock lifecycle test asserting the title stays the first prompt
line across create and settle.

Old paseo.json files with metadataGeneration.agentTitle still parse via
schema passthrough — see COMPAT(projectMetadataAgentTitle).
2026-06-17 01:12:23 +08:00
Mohamed Boudra
e202ca5036 Remove a worktree when its last workspace is archived (#1562)
* Remove a worktree when its last workspace is archived

Archiving is now workspace-centric: the UI always archives a workspace,
and a worktree's directory is removed only when its last referencing
workspace is archived (derived from ownership + a single reference
count). The "remove the worktree from disk?" prompt is gone; archiving
warns only when the workspace is dirty or has unpushed commits.

MCP archive_worktree and the CLI keep the explicit lower-level behavior:
archive every workspace backing the directory, then remove it.
Auto-archive-after-merge and create-agent auto-archive respect the
reference count like any other workspace archival.

The policy now lives in two modules - a server archive service and a
client archive hook - with the reference-count check in exactly one
place. The deleteWorktreeFromDisk request field is retained but ignored
for wire back-compat.

* Tidy archive-policy internals and close test gaps

Follow-up polish on the archive-policy consolidation; no behavior change.

Structure: the server archive service is renamed to reflect that it now
owns all archive policy (the file and its types no longer say "worktree"
where they mean "archive"); the client archive input derives the
workspace kind from the canonical type instead of re-listing it; the
not-found target is typed string|null instead of an empty-string
sentinel; a redundant workspace-update emit and the ambiguous
worktrees-root naming are cleaned up. Wire, SDK, and keybinding names are
deliberately left unchanged for back-compat.

Tests: re-home the archiving-state lifecycle and per-workspace snapshot
assertions; pin worktree-kind acceptance through archive_workspace, the
ignored deleteWorktreeFromDisk field, and three-workspace archive-all;
add a Playwright spec for the dirty/unpushed worktree archive flow (the
warning gates it, confirming removes the directory) and the bulk
remove-project confirm branch.

* Fix the hanging worktree-archive risk-warning e2e spec

The spec added in the previous commit hung deterministically, locally and
in CI. Two test-only bugs, no product change.

The archive click opens a synchronous window.confirm(); the spec
registered a passive dialog waiter but only dismissed the dialog after
awaiting the click, so the click never resolved (Playwright won't settle
a click while a dialog is open) - a circular wait. Answer the dialog
inline via page.once("dialog", ...) before the click resolves, matching
the proven worktree-archive spec.

The seed also committed on a never-pushed branch, so aheadOfOrigin was
null and the "1 unpushed commit" warning never appeared. Push the branch
to set its upstream before the local commit, so it reports exactly one
unpushed commit.

Verified: the spec now passes in ~6s (was a 45s timeout); both the
dismiss-gates and accept-removes paths run.
2026-06-17 00:38:56 +08:00
Mohamed Boudra
0fef66283a Fix pending permission MCP listing (#1561)
* Fix pending permission MCP responses

* Tighten MCP permission sanitizer typing
2026-06-16 12:53:23 +00:00
Mohamed Boudra
37de9ea796 Fix workspace-owned Playwright flows 2026-06-16 18:57:00 +07:00
Mohamed Boudra
3cf3a63423 Pass workspace ownership when creating terminals 2026-06-16 17:35:16 +07:00
Mohamed Boudra
10664487fb Add copy-to-clipboard hover action for branch and path in workspace hover card 2026-06-16 17:20:11 +07:00
Mohamed Boudra
be3a1ea6c3 Keep new workspaces recent in status groups 2026-06-16 16:41:07 +07:00
Mohamed Boudra
88b48c9d09 Add keyboard shortcut to create a new workspace
Cmd+N (Ctrl+N off mac) navigates to the new-workspace route through a
global handler keyed on the active host. The sidebar header row surfaces
the shortcut hint on hover, replacing the redundant "+" button that
duplicated the action in the Workspaces section header.
2026-06-16 16:13:03 +07:00
Mohamed Boudra
e77c8e2bf3 Always show workspace hover card with branch and directory
The hover card was previously gated to open only when a PR hint or diff
stat was available. It now opens on every desktop-web workspace hover so
users can see the workspace name, branch, and working directory. The CWD
is shortened using the existing shortenPath helper (/Users/... -> ~).
2026-06-16 15:27:02 +07:00
Mohamed Boudra
f5d39446cc Merge branch 'main' of github.com:getpaseo/paseo 2026-06-16 14:59:42 +07:00
Mohamed Boudra
64b2949f08 Key agent placement by workspace id, not directory
Agent project-placement and the fetch/list grouping still resolved a
workspace from the agent's directory, even though every agent now
carries a workspaceId. Walk workspaceId -> workspace -> project instead:
an agent whose cwd is shared with or nested under another workspace now
groups under its own workspace, not a directory-prefix match. The fetch
path's placement map is keyed by workspace id, closing the
same-directory collision.

resolveWorkspaceIdForPath survives only at the archive-by-path boundary
(auto-archive-on-merge, the MCP archive tool), where a client passes a
bare worktree path with no id; renamed to match that role.
2026-06-16 14:58:33 +07:00
Mohamed Boudra
36a136d811 Add shared trigger components and horizontal desktop workspace arguments
- Add DropdownTrigger and ComboboxTrigger with an overridable chevron slot and a 1 px translateY caret nudge for consistent vertical alignment.

- Convert text-based dropdown/combobox triggers across model selector, agent controls, mode control, settings, and new workspace to use the shared components.

- Lay out New Workspace arguments horizontally on desktop without labels while keeping the mobile vertical stack; align the project icon left edge with the title.
2026-06-16 14:56:07 +07:00
Mohamed Boudra
1812884ecd Include child results in finish notifications (#1558)
* Include child results in finish notifications

* Nest child results in system notifications
2026-06-16 15:15:45 +08:00
Mohamed Boudra
c464c2bcb4 Move tab row pins and default-pin terminal and browser 2026-06-16 14:12:18 +07:00
Mohamed Boudra
9966e49329 Key workspace status and ownership by ID, not directory
Multiple workspaces can share one checkout directory. Agent and terminal
status, sidebar tabs, and ownership now attribute strictly by workspace ID,
so a running or blocked agent in one workspace no longer shows up on its
same-directory siblings.

A one-time startup migration backfills workspace IDs onto legacy agents, and
every agent and terminal is created with one thereafter. Directory lookups
remain only for genuinely folder-derived git facts (branch, diff) and the
archive-by-path adapter.
2026-06-16 13:37:58 +07:00
Mohamed Boudra
e90f398ac0 Use only selectable Cursor ACP models (#1556) 2026-06-16 05:07:24 +00:00
Mohamed Boudra
ba9f7740a8 Fix same-directory workspace ownership 2026-06-15 22:22:31 +07:00
Mohamed Boudra
a4d4d42873 Update workspace model ownership flows 2026-06-15 22:02:18 +07:00
Mohamed Boudra
af0437e6df Update terminal activity E2E for finished attention 2026-06-15 21:07:07 +07:00
Mohamed Boudra
1c9e78616f Reduce daemon churn from terminals and archived agents
Decouple terminal list snapshots from workspace status invalidation, and filter archived stored agents before projection so seeded homes do less work.
2026-06-15 20:07:33 +07:00
Mohamed Boudra
754f6336c0 Remove pinned launchers from workspace rows 2026-06-15 15:02:38 +07:00
Mohamed Boudra
e84a3ff574 Clear terminal attention when the user views the terminal
A finished or input-waiting terminal shows an attention indicator. The
focus heartbeat already asked the daemon to clear it, but the clear was a
no-op: it guarded on activity state === "attention", while the real
tracker encodes attention as state "idle" plus an attentionReason
("finished" / "needs_input"). The state is never literally "attention",
so the indicator only ever cleared when the agent started a new turn.

The worker proxy also had no way to reach the tracker's clearAttention():
it only spoke setActivity, and setActivity("idle") is short-circuited by
the tracker while a "finished" reason is set. Add a clearAttention worker
message that calls the existing manager.clearTerminalAttention(), and
guard both clear paths on attentionReason instead of state.
2026-06-15 14:31:37 +07:00
Mohamed Boudra
713a042dc5 Add pinnable new-tab targets to the tab row and sidebar
Any new-tab target (new agent, terminal, browser, or a terminal profile)
can be pinned as a quick-launch button. Pins are global client app state
(persisted), not daemon config, so the same set shows on every workspace;
profile pins resolve per host and are dropped where that profile is absent.

The sidebar mirror launches via a navigate-then-launch intent store rather
than calling the create handlers directly: terminal creation only works in
the mounted/focused workspace, so the sidebar requests the launch and the
workspace screen consumes it on focus.
2026-06-15 14:09:29 +07:00
Mohamed Boudra
42969e40d5 Stop daemon crashes from terminal-list worker timeouts
getTerminals was the only terminal read that round-tripped the terminal
worker process. Under load the IPC call could hit its 10s timeout, and
the rejection from a fire-and-forget workspace-update emit propagated as
an unhandled rejection — taking down the whole daemon.

The daemon already maintains an authoritative mirror of terminal state
(every other read is served from it), so getTerminals now reads that
mirror locally, preserving the worker's descendant-path aggregation and
absolute-path guard. The getTerminals worker IPC request is removed
entirely, so no terminal read can stall on the worker anymore.

Also adds a defensive .catch to the two fire-and-forget workspace-update
sites, and hoists the duplicated assertAbsolutePath guard into one shared
helper in path-utils.
2026-06-15 14:09:29 +07:00
Mohamed Boudra
a4c4418164 Keep finished terminals visible as attention 2026-06-15 14:06:48 +07:00
Mohamed Boudra
71b5c35d9b Run multiple independent workspaces per directory (#1539)
* feat(workspace): bind agents/terminals/status to workspaceId (Model B phase 1)

Model B EX-Phase 1: the binding layer. Records gain an additive
workspaceId that answers "which workspace does this belong to" without
replacing the existing required cwd.

- protocol: add optional workspaceId to agent/terminal records and a
  workspaceOwnership capability under server_info.features.*
- server: resolve and persist workspaceId on agent and terminal
  creation; carry it through agent-manager, projections, storage,
  loading, sessions, and the terminal manager/worker pipeline
- workspace-directory / workspace-registry-model: derive and expose
  workspace ownership; bind status to workspaceId
- client: surface workspaceId via daemon-client
- app: filter terminals and agent visibility by workspaceId, thread it
  through workspace screen / terminal panel / session store
- tests: cover workspaceId binding across create, agent-manager,
  mcp-server, workspace-directory, workspace-registry-model, and
  agent-visibility

cwd stays required on every record; workspaceId is purely additive and
back-compatible (optional/defaulted), so old and new peers still parse
each other's messages.

Known follow-ups: none.

* feat(workspace): empty projects + editable titles, fix same-cwd terminal isolation (Model B phase 2)

- Address Phase 1 review fixes and add same-cwd terminal isolation e2e coverage
- Isolate terminal subscriptions per workspaceId so two workspaces sharing one cwd no longer cross-wire terminals (terminal-subscription-key)
- Add editable workspace title, decoupled from the backing directory/branch
- Persist empty projects (no workspaces yet) across daemon restarts
- Add e2e coverage: empty-project-persists (server + app), workspace-same-cwd-isolation, sidebar-workspace-rename

Known follow-ups: none

* feat(workspace): create multiple workspaces per directory — local or worktree (Model B phase 3)

- workspace.create RPC always creates a new workspace record; no directory dedup
- dropped directory dedup in open_project and createPaseoWorktree paths
- non-git directories are first-class: local-checkout workspaces no longer require a repo
- service-proxy collision resolved by defaulting to the owning workspace instead of failing
- new creation UI: choose backing directory (local checkout or worktree) per workspace
- e2e coverage for creating multiple workspaces over the same directory

Known follow-ups:
- COMPAT cwd->workspaceId resolver remains until client floor advances
- prune any remaining workspace==directory assumptions surfaced by usage

* feat(cli): paseo run workspace policy — bare run creates a workspace, --workspace/PASEO_WORKSPACE_ID target (Model B phase 4)

- A workspace is now the explicit home of a `paseo run`: run.ts resolves a
  workspace before creating any agent, then stamps the agent with that
  workspaceId — no run leans on createAgent's legacy cwd->workspace fallback.
- Precedence: --workspace <id> > $PASEO_WORKSPACE_ID > --worktree > bare run.
  --worktree mints its own workspace and overrides the ambient
  PASEO_WORKSPACE_ID; --worktree + --workspace is rejected upstream.
- New-workspace-per-bare-run: every bare run mints a fresh local-backed
  workspace for its cwd rather than reattaching to an existing one in that dir.
- Env support: $PASEO_WORKSPACE_ID (exported by workspace terminals) targets an
  existing workspace, same as --workspace.
- Help text: new --workspace option documents the default (new workspace per
  run) and the env fallback; created runs print the workspace id plus a tip.
- e2e: cli-run-workspace-precedence.e2e.test.ts covers bare, distinct-per-cwd,
  --workspace attach, and env attach against an isolated daemon.

Known follow-ups:
- createAgent's COMPAT cwd->workspace resolver stays for old clients; the CLI no
  longer relies on it but it is not yet removable.

* feat(workspace): archive removes the workspace record, never the directory; last worktree ref offers disk delete (Model B phase 5)

- Archive is now scoped to a single workspace RECORD (by workspaceId), not by cwd/worktree path. It tears down only the agents and terminals owned by the target workspaceId.
- Sibling isolation: a directory can back multiple workspaces, so archiving one workspace no longer destroys a sibling workspace's agents, terminals, or directory.
- Decoupled on-disk worktree deletion into an explicit, last-reference-only option (deleteWorktreeFromDisk). The directory is removed only when the archived workspace was the last active reference to a Paseo-owned worktree; local checkouts are never deleted.
- Unified archive UI with a keep/delete prompt: "Keep on disk" is the default non-destructive choice, "Delete" removes the worktree directory.
- Protocol: new optional, defaulted deleteWorktreeFromDisk field (COMPAT-tagged, back-compat preserved).
- Tests: record-scoped archive e2e (sibling isolation, last-ref disk delete, sibling-keeps-directory) and an app e2e for the keep prompt.

Known follow-ups:
- Surface the keep/delete prompt's "delete" path coverage in app e2e (only the keep path is exercised today).
- COMPAT(worktreeDiskDeletion): drop the optional gate when floor >= v0.1.97.

* feat(workspace): uniform expandable projects + status inbox; merged worktrees cleaned, explicit-id archive (Model B phase 6)

- P5 archive fixes: worktree archive now targets an explicit workspaceId, and auto-archive-on-merge cleans the worktree directory from disk when it is the last reference.
- Dropped non-git project flattening: every project — git or non-git, single- or multi-workspace — renders as the same expandable parent.
- Every project is expandable and every workspace is archivable; each carries its own "+ New workspace" affordance regardless of kind.
- Added a status inbox grouping (Ready for review / Working / Done) where each workspace is bucketed independently by last update.
- The deepest sidebar level is the workspace row: tabs, agents, and terminals never appear in the sidebar.
- Added Model B sidebar e2e coverage proving the expandable-parent, no-leaf, and independent-status-bucketing invariants.

Known follow-ups: the inline header FolderPlus worktree shortcut stays git-only (canCreateWorktreeForProjectKind); non-git projects create workspaces via the "+ New workspace" row.

* fix(protocol): use literal :: separator in terminal subscription key

The committed blob contained a NUL byte as the separator instead of the
:: the comment documents, which broke text diffs and review tooling
(git flagged the file as binary). Functionally the key is an in-memory
Map key only, but source must stay text.

* feat(workspace): shared directory right-sidebar boundary + docs; final consolidation (Model B phase 7)

- Pin and document the directory-backed vs workspace-owned right-sidebar boundary:
  same-cwd workspaces share directory-keyed git/PR/file surfaces but never share
  workspace-owned drafts, attachments, or file-explorer state. New docs section in
  architecture.md plus data-model.md keying convention and two glossary terms.
- Add e2e coverage for the boundary: same-directory-workspaces.spec.ts proves the
  right sidebar is shared (a directory change appears in both same-dir workspaces)
  while tabs/terminals stay independent per workspace.
- Accumulated follow-up fixes:
  - MCP child agents created in a new worktree are stamped with the new worktree's
    workspaceId (not the parent's, not unstamped), mirroring the session path so
    workspaceId-scoped archive can find and tear them down.
  - session-store mergeWorkspaces prunes a stale empty-project descriptor when a
    workspace lands in that project, so it stops governing the project's metadata.
  - CLI run: reject --worktree alongside an ambient PASEO_WORKSPACE_ID (not just
    --workspace), so worktree resolution never races an existing-workspace select.
    New run.test.ts pins the three validation outcomes.
- Full-branch reshape outcomes:
  - Consolidate the four placeholder server_info.features flags (workspaceOwnership,
    workspaceTitles, workspaceProjects, workspaceMultiplicity) into the single
    workspaceMultiplicity capability gate.
  - Remove the now-dead ensureLocalCheckoutWorkspace and its deps interface; explicit
    creation always mints a new same-cwd record via createLocalCheckoutWorkspace.
  - Resolve every Model B COMPAT marker from v0.1.X to v0.1.97.
  - Drop the obsolete workspaceOwnership feature assertion from the same-cwd
    isolation e2e; broaden a sidebar-shortcuts test to cover both project kinds.
  - Add workspace-create-errors.e2e.test.ts pinning each early-reject error branch.
- typecheck, lint, format all green.

Known follow-ups: pre-existing COMPAT(rewind) markers still carry v0.1.X placeholders
(out of Model B scope, left untouched).

* test(workspace): de-slop Model B tests per audit-tests (reshape mocks/assertions)

Reshape Model B test changes flagged by audit-tests so they exercise real
behavior and observable state instead of mock scaffolding.

Categories fixed:

- Mocks -> real dependencies + persisted state: create.test.ts and
  mcp-server.test.ts dropped hand-built AgentManager/AgentStorage mock objects
  and mock.calls[...] assertions, now run the real AgentManager/AgentStorage
  with a fake agent client and assert on the stored agent record (workspaceId,
  parent label).

- Module-internal spying -> injected seam: acp-agent stopped spying on the
  tree-kill module (vi.spyOn(treeKillModule, ...)). Added a ProcessTerminator
  injection seam to ACPAgentClient/ACPAgentSession (production defaults to
  terminateWithTreeKill); tests inject a typed FakeTerminator and assert on the
  recorded children plus observable stream .destroyed state.

- Poking internals -> public API: acp close()/killTerminal/releaseTerminal
  tests now create terminals through the public createTerminal API instead of
  mutating internals.terminalEntries.

- Reimplemented production logic deleted: cli-run-workspace-precedence dropped
  its inline copy of resolveRunWorkspace flag precedence (a fake reimplementation
  it then asserted against) and now proves only the daemon behaviors the CLI
  builds on; flag precedence stays covered in the CLI's own run.test.ts.

- Sleep -> poll: workspace-same-cwd-isolation replaced a fixed setTimeout with
  expect.poll on the observed snapshot state to remove the race.

- Internal protocol-frame assertions removed: sidebar-workspace-rename dropped
  the captureWsSessionFrames workspace.title.set.request assertions, relying on
  the user-visible rename + reload checks already present.

* fix: green CI — new-workspace status bucket regression + branch-picker flow tests + sdk emptyProjects

Source regression:
- packages/app/src/screens/new-workspace-screen.tsx
- packages/app/src/screens/new-workspace-empty.ts
  When a new workspace is created with an initial agent, optimistically merge it
  with status "running" (statusEnteredAt now) so it lands in the "Working" bucket
  instead of defaulting into the wrong bucket. The empty-workspace path passes
  withInitialAgent: false so a bare workspace keeps its descriptor status.

Test-drift (intentional P3 flow change — backing picker now required):
- packages/app/e2e/new-workspace.spec.ts
- packages/app/e2e/helpers/new-workspace.ts
  The reshaped creation flow requires choosing a backing ("New worktree") before
  the branch / starting-ref picker is reachable. Branch-picker specs now call
  selectWorkspaceBacking(page, "worktree") first, and the helper waits for the
  worktree control to drop aria-disabled (it stays disabled until the checkout
  status query confirms the project is a git repo) before clicking.

Test-drift (protocol field added — sdk emptyProjects):
- packages/client/src/index.test.ts
  fetch_workspaces response now carries emptyProjects; the toEqual expectation
  includes emptyProjects: [].

Flake hardening:
- packages/app/e2e/same-directory-workspaces.spec.ts
- packages/app/e2e/helpers/seed-client.ts
  Out-of-band working-tree writes raced the daemon's debounced filesystem
  watcher, so the UI could subscribe before the new file was in the git snapshot.
  Force a checkout refresh (same path as the UI's manual refresh) to make the
  write authoritative before asserting, removing the timing dependency.

* test(app): expect withInitialAgent:false in empty-workspace create call

The withInitialAgent flag (fix for the new-workspace Done-bucket regression)
added a field to the ensureWorkspace call; the empty-path unit test pinned the
exact args. Test-drift, not behavior — update the expectation.

* feat(app): stack new-workspace creation params (Project / Isolation dropdown / Base) with reserved base row + keyboard avoidance

Restructure the new-workspace creation screen into a vertical stack of
Project / Isolation / Base parameters instead of the previous mixed
layout.

- Isolation/backing is now a dropdown (Local vs New worktree) matching the
  app's existing dropdown/combobox primitives, replacing the inline
  segmented switcher.
- The Base (starting ref) row now reserves its space even when the backing
  is Local and the picker is hidden, so switching backing no longer causes
  the form to shift vertically.
- The form avoids the keyboard so the title input and submit stay visible
  while typing on native and web.
- Updated the e2e new-workspace helper to drive the dropdown-based
  backing selector and the reserved Base row.

* fix(app): simplify new-workspace form — stacked ghost rows above composer, no card/title, project-matched dropdowns

- Render the three rows (formStack) at the top, under the "New workspace"
  heading and above the composer input, not in the composer footer.
- Drop the card/surface/border chrome; rows sit on the plain background.
- Stack Project, Isolation (multiplicity only), and Base; remove the title
  field — the title is set server-side and create works with no user title.
- Each row is a Label immediately followed by its dropdown control; no
  columns, fixed label widths, or reserved horizontal space, with the label
  glyph aligned to the heading's text x.
- All three triggers share the ghost badge style of the Project control
  (ProjectPickerTrigger / IsolationPickerTrigger / RefPickerTrigger +
  Combobox); keep the workspace-create-backing-* test IDs.
- Omit "New worktree" from Isolation entirely when the project is non-git.
- Reserve the Base row height always but render nothing on Local backing,
  showing the Base label + ref picker only for New worktree.
- Keep keyboard avoidance on the centered content.
- E2E: drive selectWorkspaceBacking via the Isolation trigger + Combobox and
  remove the now-gone workspace-create-title-input usage and title field.

* feat(app): sidebar new-workspace entry points + non-git isolation hidden + project auto-select

- Q1: hide the Isolation control on non-git projects — gate the row on canCreateWorktree (multiplicity && selectedIsGit) so a project with no git checkout never offers a worktree backing choice.
- Q2: reset the stale project preselect across the reused 'new' screen — clear the manual picker choice on route project identity change so each route-driven navigation preselects its own project; align the nav verb to router.navigate.
- Q3: remove the per-project "+ New workspace" sidebar row and add one global "New workspace" entry above Sessions in both mobile and desktop sidebars (testID sidebar-global-new-workspace); creation stays reachable per-project via the existing git new-worktree icon.
- Q4: match the Sessions / New-workspace header button sizing to the workspace rows — SidebarHeaderRow icon md->sm, label fontSize base->sm.
- e2e: new new-workspace-entry.spec covering global entry, project preselect reset across reused screen, and non-git isolation hidden; update sidebar-model-b, empty-project-persists, workspace-multiplicity, and helpers for the removed per-project row.

* fix(app): group New-workspace/Sessions header (no divider, workspace-row sized) + remove banned useUnistyles

- Q5: wrap the New-workspace and Sessions header entries in a single sidebarHeaderGroup that owns one bottom divider, so the two rows sit tight together with no gap and no per-row separator (both mobile and desktop sidebars).
- Add a compact variant to SidebarHeaderRow: workspace-row sized (minHeight 36, surfaceSidebarHover, borderRadius.lg) with horizontal padding that aligns its icon/label with the Workspaces section title and the workspace rows below; the default header variant (settings Back-to-workspace) keeps its sidebar-height row and own separator.
- Remove the banned useUnistyles() from sidebar-header-row.tsx per docs/unistyles.md: theme-reactive icon color now goes through withUnistyles(Icon) + uniProps mappings; static sizing reads ICON_SIZE.
- Taste: new-workspace-screen dedupes the project-icon styles (single projectIcon/projectIconFallback/projectIconFallbackText) and replaces repeated magic numbers with BADGE_HEIGHT and a named fallback-font-size constant.

* fix(app): no layout shift on git<->non-git (reserve Isolation row) + symmetric sidebar divider spacing

- Q6: Isolation row reserves its height and renders an invisible spacer for non-git projects, matching the Base-row pattern, so switching between git and non-git projects keeps a constant form height with no layout shift.
- Q7: sidebar header group splits paddingVertical into paddingTop/paddingBottom so the Sessions-row-to-divider gap equals the divider-to-Workspaces-header gap, centering the divider.

* feat(app): rename Sessions to History (clock icon, Agent history header)

- Sidebar label now reads "History" with a clock icon (was Sessions / MessagesSquare)
- Sessions screen header now reads "Agent history"
- Updated i18n strings across all 6 locales (en, ar, ru, zh-CN, fr, es)
- Route and testIDs unchanged (sidebar-sessions, /sessions)

* fix(app): symmetric sidebar header padding (top == bottom), traffic-light inset preserved

The sidebarHeaderGroup wrapper (New-workspace + History rows) had paddingTop: theme.spacing[1] against paddingBottom: theme.spacing[2]. Equalize paddingTop to theme.spacing[2] so the header group's top padding matches its bottom padding. The divider stays centered since paddingBottom still matches WorkspacesSectionHeader's paddingTop. The desktop window-controls (traffic-light) spacer — paddingTopSpacerStyle plus TitlebarDragRegion — is a separate inset and is left untouched.

* fix(app): hover card shows branch; sidebar copy-branch copies the real branch (not the title)

- Add SidebarWorkspaceEntry.currentBranch, sourced from gitRuntime.currentBranch (normalized; detached HEAD/blank/missing -> null)
- Fix copy-branch smear: handleCopyBranchName copied workspace.name (the title); now copies the real currentBranch (guarded)
- Workspace hover card: add a branch row (GitBranch icon + branch name), shown only when it differs from the title
- Header branch-switcher left untouched; diff-pane re-home decision still pending

* feat(app): branch switcher moves into the git diff panel; header title is static (Model B coherence)

- branch switcher now lives in the diff panel Changes header on desktop+mobile
- workspace header title is a plain static title; branch removed from it
- no new workspace-screen header row
- git diff-stat unchanged, stays where it is
- no duplicate git actions
- unified descriptor name fallbacks via resolveWorkspaceName
- rename-then-switch e2e proves header title and real branch stay independent
2026-06-15 14:23:02 +08:00
Yurui Zhou
eb94b70848 feat: hide dotfiles in file explorer (#1516)
* feat: hide dotfiles in file explorer

* refactor: rename to "hidden files" and read flag from store

Rename the explorer dotfile toggle to show-semantics "hidden files"
so copy and code share one cross-platform vocabulary, and stop
prop-drilling the flag now that it lives in the panel store.

- explorerShowHiddenFiles (default true) replaces explorerHideDotFiles;
  i18n keys + translations switched from "dotfiles" to "hidden files"
- imperative helpers read usePanelStore.getState() instead of taking
  the flag as a parameter; flag stays threaded only through the
  reactive tree-row render path
- drop dead usePanelState exposure; collapse migration double-guard

* fix: address Greptile review on hidden-file path matching

- isHiddenExplorerPath no longer treats ".." segments as hidden
  (only "." was exempt before); add tests for ".." and "../sibling"
- assert the showHiddenFiles i18n key alongside hideHiddenFiles

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-06-15 12:28:17 +08:00
Mohamed Boudra
f4450cda51 fix(app): prevent Android crash when opening Providers settings (#1537)
Closes #1504
2026-06-15 00:15:47 +08:00
范东辉
43e17c1e7c fix: pass appBaseUrl to generateLocalPairingOffer in daemon pair RPC (#1187)
Threads the configured app.baseUrl through daemonRuntimeConfig to generateLocalPairingOffer in the daemon pair RPC path, so `paseo daemon pair` honors a custom app.baseUrl when the daemon is already running.

Fixes #1494
Fixes #1186
2026-06-15 00:06:52 +08:00
yeshan333
a6ec352f07 Use terminateWithTreeKill for ACP agent child process cleanup (#1460)
* fix(server): use terminateWithTreeKill for ACP agent child process cleanup

ACPAgentSession.close() was using child.kill("SIGTERM") which only
signals the direct child process (e.g. npx), leaving grandchild
processes (sh -c, node qodercli) alive as orphans. Over time these
accumulate and consume significant memory (~330MB each).

Replace all direct child.kill("SIGTERM") calls in ACP agent lifecycle
with terminateWithTreeKill(), which recursively walks the process tree
via /proc on Linux and taskkill /T on Windows. This aligns ACP with
OpenCode, Pi, and Codex providers which already use tree-kill.

Affected code paths:
- ACPAgentSession.close() — main child process
- ACPAgentSession.close() — terminal child processes
- ACPAgentSession.killTerminal()
- ACPAgentSession.releaseTerminal()
- terminateChildProcess() helper (used by closeProbe and init failure)

Removes the now-unused waitForChildExit() helper.

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

* fix(server): address acp tree kill review

* test(server): cover acp release terminal tree kill

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-14 23:55:27 +08:00
Mohamed Boudra
0d76654ba0 Show activity indicators for terminals and their workspaces (#1507)
* feat: show activity indicators for terminals and their workspaces

A terminal surfaces whether the agent running inside it is working, idle,
or waiting for input as a tab dot, and that state rolls up into the
owning workspace status — so an agent run inside a terminal reads the
same as a native agent. Finishing a turn raises a "Terminal finished"
notification that routes back to the workspace terminal tab.

Activity is reported by the agents, not parsed from terminal output:
Claude and Codex command hooks and an OpenCode plugin post coarse state
to a local daemon endpoint, and a source-agnostic per-terminal tracker
maps it to working/idle/attention. Hook installation is opt-in and off
by default because it edits your real agent config files; enable it under
a host's Terminal settings and Paseo installs the hooks globally, runs
them via `paseo hooks <agent> <event>`, and removes its own hooks when
you opt back out. Installation and reporting are idempotent and
fail-open, so a missing or failing hook never breaks the user's shell.

* Fix terminal activity routing and CI test

Test drift: anchor the provider-leak test to the repo root instead of process.cwd() so it passes under the server workspace CI runner.

Also restore auth-first JSON parsing outside the terminal-activity route and route subdirectory terminal activity to the deepest active workspace.

* Fix workspace resolver regression

Code drift: terminal activity routing needs parent-workspace prefix matching, but agent directory and historical fetches keep exact registered workspace semantics, including archived exact matches.

* Narrow terminal/draft workspace subscriptions to the fields they use

terminal-panel and the draft agent tab subscribed to the whole workspace
descriptor via useWorkspace but read only workspaceDirectory (plus projectKind
in TerminalPanel, plus id in the draft tab), so they re-rendered on any field
change of that workspace (status, diffStat, scripts). Switch them to the
existing useWorkspaceDirectory / useWorkspaceFields hooks so they re-render only
when the fields they actually read change. No behavior change.

* Make TerminalActivity.state forward-compatible

A strict z.enum rejects the whole payload when a newer daemon reports a
terminal activity state this client predates, breaking the protocol's
old-client-parses-new-daemon contract (the activity field has no .catch at
its embedding in messages.ts, so the failure bubbles up). Degrade unknown
states to "idle" (no indicator, no notification) via .catch, matching the
enum-with-catch pattern used elsewhere in the protocol. Add a forward-compat
regression test.
2026-06-14 23:29:14 +08:00
Mohamed Boudra
21deb08673 Make workspace IDs opaque, independent of the filesystem path (#1503)
* Make workspace IDs opaque, independent of the filesystem path

Workspace IDs were the resolved checkout/worktree path, so code could
treat an ID as a path: prefix matching, deriving directories from it,
falling back to a path when a lookup missed. IDs are now opaque -
compared only by exact equality and never used as a path. Anything
path-shaped resolves the workspace record first and reads its cwd.

New workspaces get a generated `wks_` ID; existing path-shaped IDs are
read from disk and never regenerated, so there is no migration and no
change to the wire or persisted schemas. Groundwork for running
multiple workspaces in a single directory.

* Keep attachment scope key stable and align refetch test

The attachment scope key keeps `workspace=` instead of `wsid=` so existing
persisted drafts are not orphaned; the rename was cosmetic. The SDK refetch
test no longer asserts a filter, matching refetch fetching one page and
selecting by id client-side.

* Fix live agent updates in directories without a registered workspace

The opaque-ID work removed buildProjectPlacementForCwd's directory
fallback, so an agent running in a folder with no registered workspace
(e.g. a fresh non-git dir) produced a null placement. forwardAgentUpdate
then threw "Workspace not found", the error was swallowed by its catch,
and no agent_update was emitted — live model/thinking switches and status
updates silently stopped. Caught by the live-preferences e2e suite.

The fallback builds a directory-scoped project placement keyed by the
path. That key is a project grouping key (non-git projects group by
path), not a workspace id, so it stays within the opaque-id rule.

* Always run server worktree archive, even without a resolved workspace

Archiving bailed out entirely when the workspace was not found in the
client store, so a race or stale state could make "archive" do nothing
server-side with only a console.warn. The server archive is keyed by
worktreePath, which is always available, so it now runs regardless; only
the optimistic client-side updates (keyed by workspace id) are gated on
the workspace being resolved.

* Expect a directory-scoped placement for unregistered agent dirs

This unit test asserted the no-placement behavior reverted in the live
agent-update fix, which had broken live model/thinking switching. Update
it to expect the directory-scoped placement now emitted for an agent in a
directory with no registered workspace.

* Fix opaque workspace routing in app E2E

* Preserve workspace IDs during partial bootstrap

* Fix archive flows for opaque workspace IDs

* Stop treating opaque workspace IDs as filesystem paths

Workspace IDs are opaque (wks_<hex>), but several call sites still passed
the id where a directory was expected, which broke those flows for opaque
IDs. The branch switcher sent the id as a cwd to branch/stash/checkout git
operations, and server archive/reconcile cleanup keyed git-watch and
subscription teardown by id, leaking that state.

Git and filesystem operations now take the workspace directory; the opaque
id is used only for identity and cache keys. Archive/reconcile cleanup
routes through a single teardownArchivedWorkspace helper that keeps the key
split explicit: runtime store by id, git watch and subscription by cwd.
Path-derived grouping keys are renamed to directory keys, and the helpers
are split into workspace-identity and workspace-directory so the id-vs-path
boundary is obvious.
2026-06-14 22:03:27 +08:00
itMrBoy
f352072dac Fix coding-agent terminal shortcuts not working on Windows (#1509)
* Fix coding-agent terminal shortcuts not working on Windows

On Windows, opening a Terminal profile (Claude Code / Codex / OpenCode) did
nothing, and afterwards even a plain New Terminal stopped working until restart.

Three Windows-specific problems combined:

- A failed node-pty conpty spawn completes asynchronously on its conout worker
  thread; the uncaught exception escaped the per-request try/catch and crashed
  the whole terminal worker, severing every terminal. Add uncaughtException /
  unhandledRejection guards so a single bad spawn keeps the worker alive.
- Profile commands were passed to conpty unresolved. conpty's CreateProcess
  ignores PATHEXT, so bare codex (npm codex.cmd) wasn't found and .cmd/.bat
  shims can't run directly. Add resolveTerminalSpawnCommand() to resolve the
  real path and route .cmd/.bat through cmd.exe /c on Windows.
- winget-installed CLIs (e.g. Claude Code) live under the winget Packages dir
  and aren't on PATH. findExecutable() now falls back to those known install
  locations, so all providers and terminal profiles benefit.

Also surface a failed terminal create to the user via a toast instead of
silently dropping the error.

* Address review: simplify winget scan, narrow worker exception guard

- Drop the single-element WINGET_PACKAGE_BIN_SUBDIRS loop in executable.ts and
  map package dirs directly to the root <name>.exe candidate.
- Remove the unhandledRejection handler in the terminal worker (only the
  uncaughtException path is exercised by conpty's async spawn failure) and
  expand the comment to explain the keep-alive trade-off and the absence of a
  worker restart path.

* Refactor executable resolution out of utils

* Serialize terminal create requests in worker

---------

Co-authored-by: danniel <liminfhu@gmail.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-06-14 19:48:42 +08:00
qer
bf8f2510c8 fix(acp): make ACP/Kimi sessions importable again (#1510)
ACP-based providers (Kimi and other custom ACP agents, Copilot, Cursor) were
absent from the import session picker. Two causes, both in the import path:

1. Never queried. The import refactor (51d1d007) gated provider listing on
   capabilities.supportsSessionListing and set it for claude/codex/opencode/pi
   but not for ACP providers, so agent-manager skipped every ACP provider in
   listImportableSessions — the picker showed 'no sessions' even though
   listImportableSessions is fully implemented. Set supportsSessionListing on
   DEFAULT_ACP_CAPABILITIES and COPILOT_CAPABILITIES. Agents that don't support
   session/list still return an empty list at the runtime probe, so this only
   controls whether the daemon queries them.

2. Ignored cwd. listImportableSessions never forwarded the requested cwd to the
   agent's session/list call, so agents returned globally-recent sessions that
   the import limit could truncate before the current directory's sessions were
   reached. Forward options.cwd as the session/list cwd filter (with the
   pagination cursor) so the agent filters by directory at the source.
2026-06-14 14:52:12 +08:00
Mohamed Boudra
72b67f48e3 Eliminate spiky terminal lag under load (#1500)
* Eliminate spiky terminal lag under load

Terminal output stuttered during heavy output and when the daemon was
busy. Two compounding causes, both confirmed by measurement:

- Every 256KB of output, the daemon dropped pending frames and re-sent a
  full cell-grid snapshot (cloned across IPC, stringified to JSON) — a
  10MB build did this ~40 times. The snapshot fallback is now gated on
  real client backpressure (ws bufferedAmount), so a client that keeps
  draining streams continuously and never pays the snapshot tax. This
  also removes the periodic GC hitch the churn caused.
- Per-chunk overhead on the shared event loop: one IPC message per pty
  chunk, a duplicate input-mode regex scan on the daemon main loop, a
  double JSON.stringify per outbound message, and 16KB string realloc
  per chunk. Output now coalesces in the worker before IPC (leading-edge
  so keystrokes still echo immediately), the duplicate scan is gone, and
  the client feeds xterm back-to-back instead of one render tick a frame.

Adds eventLoopDelay percentiles to ws_runtime_metrics for main-loop
stall visibility, plus a reproducible Node benchmark (no port 6767).

Measured: echo p50 7.7ms to 2.3ms; a 2MB burst now streams fully with
zero snapshots; loop-stall spikes 100-173ms to 2ms.

* Guard trace-level check against partial logger stubs

The isLevelEnabled gate added for the emit() perf fix crashed every
session test that injects a hand-rolled logger stub (10 of them omit
isLevelEnabled). Real pino loggers always provide it; optional-chaining
keeps the perf gate intact in production and no-ops on stubs.

* Make recent-output exit-summary test deterministic

The added test self-exited the child immediately after writing 3000
lines, then asserted the newest line was in the exit summary — but the
summary reads the headless xterm buffer, which parses writes
asynchronously, so a loaded CI runner saw a stale buffer (line-2707 not
line-2999). Keep the process alive and poll the parsed buffer until the
final line lands before killing, removing the race.

* Skip ConPTY-fragile worker terminal tests on Windows

The coalescing and input-mode-preamble tests assert byte-contiguous PTY
output and an exact kitty-escape round-trip. Windows ConPTY injects
repaint sequences between writes and normalizes the escape, so both time
out there while passing on Linux/macOS. Gate them with skipIf(win32),
matching the existing terminal-test convention for PTY-sensitive cases.

* Apply terminal barriers immediately when no writes are pending

A barrier op (snapshot/restore/clear) only needs the sentinel-write gate
to wait out plain writes still parsing in xterm's buffer. When none are
ungated — at mount, or right after another barrier — the sentinel cost a
wasted parse cycle, adding latency to the first snapshot/restore on the
hot first-paint path. Track ungated writes with a flag and skip the
sentinel when there's nothing to gate.

* Fix mobile startup saved-host e2e

* Unskip Windows worker terminal coverage

* Tighten worker coalescing assertion

* Reset terminal ungated writes on unmount

* Fix terminal regressions found in adversarial review

Three issues this PR's terminal changes introduced:

- Worker snapshot could duplicate coalesced output. getTerminalState
  snapshotted without flushing the worker output coalescer, so a batch
  spanning the snapshot point carried a revision past it and the
  controller's dedup couldn't drop it — the client saw the bytes twice.
  Flush before snapshotting.
- Relay clients lost backpressure protection. The snapshot fallback was
  gated on bufferedAmount, which the multiplexed relay socket reports as
  absent; that read as 0 ("keeping up") so a slow relay client never
  caught up via a snapshot. Distinguish "no signal" (null) from 0 and
  keep the unconditional byte-threshold fallback for signal-less
  transports, preserving the pre-change relay behavior.
- Recent-output buffer was no longer a hard cap. A single chunk larger
  than the limit was retained whole; slice its tail.

Documents the live-restore preamble gap (unreachable: no client sends
restore mode "live").

* Skip input-mode preamble test on Windows ConPTY

CI confirmed Windows ConPTY normalizes away the kitty keyboard escape
the child writes, so it never reaches the worker's input-mode tracker
and the preamble stays empty — the test times out. ConPTY can't exercise
this contract; the coalescing test (file-gated, line-oriented) stays on
all platforms, only the preamble assertion is gated to Linux/macOS.
2026-06-13 05:38:58 +00:00
paseo-ai[bot]
442a1b9a5a fix: update lockfile signatures and Nix hash [skip ci] 2026-06-13 02:21:51 +00:00
Mohamed Boudra
ef750e0a85 Unify attachment display and enable file uploads on all platforms (#1501)
Attachment pills were drawn by four separate style sets, so files,
images, GitHub items, and reviews rendered at different heights in the
same tray. Move the frame, the two-line label, and the image thumbnail
into shared components so every attachment lines up at one height,
across both the composer and sent messages.

Sent-message images now open the lightbox, and file uploads work on
iOS and Android via expo-document-picker instead of being gated to
desktop.
2026-06-13 10:18:39 +08:00
Mohamed Boudra
a5c4ba4d89 Fix slash commands in imported session previews (#1502) 2026-06-13 10:16:49 +08:00
paseo-ai[bot]
f4687668e1 fix: update lockfile signatures and Nix hash [skip ci] 2026-06-13 01:50:29 +00:00
Mohamed Boudra
8ef75c0914 chore(release): cut 0.1.96 2026-06-13 08:46:33 +07:00
Mohamed Boudra
a9ea3c76a3 Changelog for 0.1.96 2026-06-13 08:45:21 +07:00
Mohamed Boudra
e0463ad17f Keep settled mobile sidebars hidden under UI load
The earlier ghost-open fix gated re-commits on a settle counter, but on
Fabric that doesn't hold: Reanimated re-applies its own (stale) animated
props over React's committed props on every commit, so a settled-closed
sidebar still reappears after a heavy commit (e.g. a long thread mounting
or a message send) — reanimated#9635. No committed transform/opacity
value can win while a useAnimatedStyle is attached.

Split ownership instead: React owns whether the overlay exists, the
worklet owns its motion. display lives on the plain wrapper View that
Reanimated never touches, so a hidden overlay stays hidden regardless of
what the animated props revert to. An open gesture flips an overlayPeek
flag to reveal the panel before the store updates; a short close grace
keeps it displayed until the close animation finishes.

Residual: a heavy commit landing mid-close-animation can still flash the
panel until the animation settles — transient, not the previous permanent
wedge.
2026-06-13 08:34:52 +07:00
paseo-ai[bot]
04a985bf23 fix: update lockfile signatures and Nix hash [skip ci] 2026-06-12 17:23:22 +00:00
Mohamed Boudra
10a556d6a1 chore(release): cut 0.1.95 2026-06-13 00:18:28 +07:00
Mohamed Boudra
fbd3dca7ed Changelog for 0.1.95 2026-06-13 00:16:42 +07:00
Mohamed Boudra
5b321c0644 Update ACP provider catalog version pins 2026-06-13 00:16:18 +07:00
Mohamed Boudra
b0ca08a452 Keep git review state fresh after reconnect (#1491) 2026-06-12 16:55:18 +00:00
Mohamed Boudra
8cd51c8a47 Clarify attachment upload icons 2026-06-12 22:52:41 +07:00
Mohamed Boudra
2c27f21a90 Treat SVG attachments as files 2026-06-12 22:52:41 +07:00
Mohamed Boudra
a929ea9726 Show an error for missing project paths (#1490)
* Fix missing project paths flashing in the sidebar

* Log project lifecycle changes

* Address project picker review feedback

* Show project picker transport errors

* Inject session filesystem for workspace tests
2026-06-12 23:42:21 +08:00
Mohamed Boudra
c4d544baf6 Reduce workspace render churn 2026-06-12 21:53:22 +07:00
Mohamed Boudra
889d058658 Move Terminal profiles section to end of workspace menu 2026-06-12 21:51:08 +07:00
Mohamed Boudra
85fa38bdde Fix git action priorities (#1488) 2026-06-12 21:57:35 +08:00
Mohamed Boudra
736b2da1a2 feat(app): add drag-and-drop file upload and file picker to composer (#1474)
- Desktop web: drag-and-drop non-image files onto the chat pane to upload
- Desktop web: file picker option in the composer attachment sheet
- Uploaded files are sent to the LLM as filename references in a tmp directory
- New useFilePicker hook for cross-platform file picking
- Extended useFileDropZone to support generic file drops
- Added UploadedFileAttachment type to protocol and composer attachment system
- Fixed adaptive modal sheet closing on backdrop press in selection mode
2026-06-12 21:56:51 +08:00
Mohamed Boudra
9c43e0222a Fix new workspace keyboard overlap (#1489) 2026-06-12 13:44:26 +00:00
paseo-ai[bot]
4824cfd05b fix: update lockfile signatures and Nix hash [skip ci] 2026-06-12 12:57:00 +00:00
Mohamed Boudra
7e46f6a647 Rework docs navigation and move alternatives to top-level pages (#1487)
* Rework docs navigation and move alternatives to top-level pages

- Add nested docs nav with categories, collapsible groups, breadcrumbs,
  and a right-hand page outline with heading slugs.
- Move alternatives out of /docs/alternatives into /alternatives/*
  marketing pages with 301 redirects.
- Add github-slugger for heading IDs and require explicit width on
  SiteShell so new pages can't accidentally pick the narrow prose layout.
- Remove the best-practices doc from public-docs.

* Make docs content column prose-width
2026-06-12 12:53:30 +00:00
Mohamed Boudra
fb0c5b27f3 Fix pull request image previews for uploaded attachments (#1486) 2026-06-12 20:30:30 +08:00
Mohamed Boudra
a3951495d4 refactor(app): improve terminal profile modal form and zinc button contrast
- Extract terminal profile add/edit modal into a reusable component with shared FormField/FormTextInput primitives.
- Add inline validation, save-pending state, and keyboard navigation.
- Fix zinc theme primary button foreground contrast by using accentForeground/destructiveForeground tokens.

Closes #1484
2026-06-12 19:30:14 +08:00
Mohamed Boudra
00a6e636fb Fix mobile sidebars reappearing ghost-open after heavy commits
On Android (Fabric), a large React commit landing after a sidebar
open/close settles — e.g. a long agent thread finishing its first
mount — re-applies React's stale committed props onto the native view,
reverting the UI-thread transform so the sidebar reappears with no
transition while store and worklet state correctly say "closed"
(software-mansion/react-native-reanimated#9635).

Work around it by bumping a settled-generation counter after every
settle so the animated styles refresh React's committed props from the
settled shared values. The backdrop's pointerEvents moves from the
worklet to React state for the same reason: Reanimated's prop
re-application doesn't cover it, and a reverted "auto" wedges an
invisible tap-eating backdrop.

Animations still run entirely on the UI thread; the bump only fires
after settle and writes no shared values.
2026-06-12 16:50:32 +07:00
Mohamed Boudra
9af18ef2f5 Fix 0.1.94 changelog attribution 2026-06-12 14:06:05 +07:00
paseo-ai[bot]
fd0793dc02 fix: update lockfile signatures and Nix hash [skip ci] 2026-06-12 06:42:39 +00:00
Mohamed Boudra
e3a5402557 chore(release): cut 0.1.94 2026-06-12 13:38:52 +07:00
Mohamed Boudra
7f8f42e92e Prepare 0.1.94 release 2026-06-12 13:37:43 +07:00
Mohamed Boudra
426547bfcc Fix composer send shortcut conflicts 2026-06-12 13:29:51 +07:00
Mohamed Boudra
9e9d0ee305 Fix pull request panel markdown on native 2026-06-12 13:18:01 +07:00
Mohamed Boudra
f930956e5c Add public docs page for release channels and updates 2026-06-12 12:56:18 +07:00
paseo-ai[bot]
21d7f8cb2a fix: update lockfile signatures and Nix hash [skip ci] 2026-06-12 05:46:44 +00:00
Mohamed Boudra
3163e212fb Attach pull request feedback to chat (#1400)
* feat(app): add pull request panel context actions

Attach pull request comments, reviews, and failed check details to chat context. The PR panel now lives in its own module so the activity, query, and context policies have one home.

* fix(server): mark capped check details as truncated

* fix(app): preserve concurrent PR panel attachments

* fix(server): preserve annotation truncation state

* fix(app): keep matrix check attachments distinct

* fix(app): streamline pull request context attachments

* fix(app): simplify pull request feedback rendering

* feat(app): group pull request review threads

* fix(app): tidy pull request panel boundaries

* fix: use worktree cwd for dev services

* fix(app): keep git query keys UI-free

* feat(app): collapse resolved comments and add chat actions to headers

* fix: address PR feedback and Playwright flakes

* fix(app): hide check chat action without attachment scope

* fix(server): dedupe inline review timeline comments
2026-06-12 13:43:01 +08:00
Mohamed Boudra
c5da7505da Show Claude skills in inline autocomplete 2026-06-12 12:28:06 +07:00
Mohamed Boudra
d84d5f66cd Merge remote-tracking branch 'origin/main' 2026-06-12 12:12:46 +07:00
维她命@
e4c966b21e fix(app): add missing i18n keys for host settings (#1478)
* fix(app): add missing i18n keys for host settings

The client UI i18n migration (#1282) referenced translation keys that
were never added to any locale file, so i18next rendered the raw key
string in Settings → Host (e.g. a button labeled
"settings.host.daemon.restart.confirm").

Add the six missing keys to all six locales with real translations:
- settings.host.daemon.rename.submit
- settings.host.daemon.restart.confirm
- settings.host.orchestration.systemPrompt.{edit,reset,save,saving}

The seventh broken reference, in pair-device-modal.tsx, used the wrong
namespace (settings.integrations.pairDevices.rowTitle); the key is
defined at settings.host.pairDevices.rowTitle. Point the reference there.

Reported in https://github.com/getpaseo/paseo/pull/1282#issuecomment-4686672699

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(app): correct machine-translated Save/Saving labels (es/fr/ar)

#1282 left several Save/Saving labels machine-translated into the
"economize money" sense rather than the persist-data sense, so users saw
"Ahorro..." (es), "Économie..." (fr), and "توفير..." (ar) where "Saving..."
was meant.

Fix renameModal.saving and settings.project.actions.{save,saving} in the
three affected locales. ar's actions.save also moves from the conjugated
"يحفظ" to the standard button form "حفظ". Wording matches each locale's
already-correct siblings (e.g. es "Proyecto guardado") and the keys added
in the previous commit. ru and zh-CN were already correct and are untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 05:10:24 +00:00
维她命@
f05544ef8f Authenticate injected agent MCP when daemon password is set
Fixes #914
Fixes #690
2026-06-12 13:06:20 +08:00
Mohamed Boudra
fa800ae78e Add configurable terminal profiles 2026-06-12 11:47:00 +07:00
Mohamed Boudra
f242519623 Add Copy file path to file preview tab menus (#1473)
* feat(app): add Copy file path to file preview tab menus

* fix(app): restore copyAgentId translations accidentally replaced by placeholder
2026-06-12 11:09:22 +08:00
Mohamed Boudra
75aa626df5 fix(app): preserve saved host shell on reconnect 2026-06-12 08:40:29 +07:00
Mohamed Boudra
cd0aaed1e2 fix(app): restore mobile reconnect welcome 2026-06-12 08:13:45 +07:00
Mohamed Boudra
1b6f9300a7 fix(server): keep worktree terminals out of parent workspaces 2026-06-12 08:13:45 +07:00
Mohamed Boudra
62d9e656d7 Bound retained workspace screens (#1472)
* fix(app): prune retained workspace deck entries

* fix(app): narrow workspace deck retention selectors

* fix(app): preserve deck lru during route transitions

* test(app): update startup reconnect expectation
2026-06-11 17:00:05 +00:00
Mohamed Boudra
c0614c3207 Merge branch 'main' of github.com:getpaseo/paseo 2026-06-11 16:10:11 +07:00
Mohamed Boudra
d802534e7b fix(app): preserve workspace route during desktop refresh 2026-06-11 16:08:50 +07:00
Mohamed Boudra
9fb64f5a0b fix(app): accept inline skill autocomplete (#1464) 2026-06-11 16:16:26 +08:00
Mohamed Boudra
f5a7055e7e docs: trim self-hosted relay instructions 2026-06-11 13:49:59 +07:00
Mohamed Boudra
d3a6bf80dd fix(app): clean up i18n settings labels 2026-06-11 13:48:06 +07:00
Mohamed Boudra
69f0fa07b4 docs: add Simplified Chinese README 2026-06-11 13:42:01 +07:00
Mohamed Boudra
fd341de848 Merge branch 'main' of github.com:getpaseo/paseo 2026-06-11 13:20:29 +07:00
paseo-ai[bot]
33f77e335f fix: update lockfile signatures and Nix hash [skip ci] 2026-06-11 06:18:58 +00:00
Mohamed Boudra
a70b5c1e1d fix(app): reuse draft command cache across tabs 2026-06-11 13:17:29 +07:00
chyendongnhanh338
08ebe5e7e1 Complete client UI i18n migration
Adds client-side i18n support, language settings, translated UI copy, and locale parity coverage.
2026-06-11 14:15:35 +08:00
paseo-ai[bot]
41cf070fd8 fix: update lockfile signatures and Nix hash [skip ci] 2026-06-11 05:45:06 +00:00
Mohamed Boudra
65342b338a Merge branch 'main' of github.com:getpaseo/paseo 2026-06-11 12:41:14 +07:00
Mohamed Boudra
f639c4cba6 fix(protocol): allow future agent capability flags 2026-06-11 12:40:07 +07:00
Mohamed Boudra
a682476d11 feat: add iOS simulator worktree preview
Adds the worktree service, setup cache seeding, and Metro preview mount for /.sim. Also includes the related startup and mobile sidebar fixes needed by the updated worktree app flow.
2026-06-11 12:23:53 +07:00
Mohamed Boudra
b2cd61a6fe Keep PR status current after agent merges (#1455)
* feat(server): refresh PR state promptly after agent merges via gh CLI

When an agent merges a PR with gh pr merge, the app could take up to
2 minutes to show the merged state because:

- gh pr merge is a remote GitHub operation that does not touch local git refs,
  so file watchers never fire
- the GitHub self-heal poll runs on a 2-minute slow interval for settled PRs
- no refresh is triggered when agent tool calls complete

Add a detection path that intercepts completed shell tool calls in the
agent manager and signals the workspace git service when a command may
have changed external state (gh *, git push, npm publish). The git
service then invalidates the GitHub cache and schedules a forced
GitHub-inclusive refresh, so the updated PR state arrives within ~500ms.

The agent manager does not leak cache semantics; it calls a semantic
onWorkspaceStateMayHaveChanged callback and lets the git service decide
what that means internally.

* refactor(server): share workspace refresh merge logic
2026-06-11 12:20:24 +08:00
Mohamed Boudra
2828dce65f fix(app): advance multi-question prompts step by step (#1462) 2026-06-11 03:45:32 +00:00
Mohamed Boudra
6db285b1d9 docs: add claude code page explaining anthropic agent sdk usage policy 2026-06-10 17:47:13 +07:00
Mohamed Boudra
201f427f62 fix(app): keep settings sidebar scrollable 2026-06-10 17:02:58 +07:00
Mohamed Boudra
f7bef36606 feat(landing): replace Copilot and OMP with Cursor in hero badges
Updates the landing page featured agent badges and multi-provider section to show market leaders: Claude Code, Codex, OpenCode, Pi, and Cursor. Replaces the Copilot and OMP badges, updates FAQ references, and uses the proper Cursor SVG from the app catalog.
2026-06-10 16:04:18 +07:00
Thomas Aull
5ea586a926 Preserve Pi import model and thinking (#1441)
* Preserve Pi import model and thinking

* Format Pi import config helper

* Preserve Pi thinking in large imports

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-06-10 16:52:15 +08:00
Mohamed Boudra
8e6455f1d6 docs: add workspaces page explaining project/workspace/session model
Adds a new conceptual Workspaces doc that explains how Paseo organizes work around projects, workspaces, and sessions. Reframed the existing Git worktrees page as the technical deep-dive it always was. Updated glossary with agent session and tab terminology. Reordered docs nav so workspaces and worktrees come before providers.
2026-06-10 15:50:19 +07:00
Aoxiong Yin
7be9cc0d0b fix(server): show Other input for Claude questions
Fixes #1421
2026-06-10 16:49:24 +08:00
Mathias Kurz
e446d9009f feat: add Antigravity as an "Open in editor" target (#1424)
Registers the Google Antigravity IDE so the workspace Open-in-editor
picker detects and launches it (PATH probe for the "antigravity"
command), alongside Cursor, VS Code, WebStorm, and Zed.

- desktop: add the target to BUILT_IN_EDITOR_TARGETS (kind: editor).
- app: add "antigravity" to the known editor-target id set and map its
  bundled icon.

Co-authored-by: Mathias Kurz <mkurz@stamus-networks.com>
2026-06-10 15:30:03 +08:00
paseo-ai[bot]
915cbda8a7 fix: update lockfile signatures and Nix hash [skip ci] 2026-06-10 06:59:14 +00:00
Mohamed Boudra
5631eb17ea chore(release): cut 0.1.93 2026-06-10 13:55:00 +07:00
Mohamed Boudra
49e09ee660 Update changelog for 0.1.93 2026-06-10 13:53:47 +07:00
Captain
56b1def06e Add Claude Fable 5 to the model catalog (#1443)
* Add Claude Fable 5 to the model catalog

Fable 5 (`claude-fable-5`) is the new top-tier Claude model above Opus,
but the Claude provider's model list is hardcoded in `CLAUDE_MODELS` and
was not fetched dynamically, so it never appeared in the selector.

- Add `claude-fable-5` to `CLAUDE_MODELS` (non-default; Opus 4.8 stays the
  default). Uses the Opus extended-thinking effort levels (low/medium/high/
  xhigh/max). No `[1m]` variant: Fable 5 is natively 1M context, unlike the
  Opus/Sonnet entries whose `[1m]` opts a 200K-default model into 1M.
- Teach `normalizeClaudeRuntimeModelId` the Fable family. Its version is a
  single segment (`fable-5`) rather than the `{major}-{minor}` scheme, so it
  needs its own match to map dated runtime IDs back to the catalog ID.
- Update the model-list and normalization test expectations, plus the CLI
  provider e2e catalog fixture.

Fast Mode is correctly unaffected — its allowlist is Opus 4.6/4.7/4.8 only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Simplify Fable runtime-ID normalization

Drop the unused `[1m]` capture from the Fable match branch. There is no
`claude-fable-5[1m]` catalog entry — Fable 5 is natively 1M context, so
there is no 200K-default model to opt into 1M — which made that path dead
code (and an untested one). Keep the `[-_ ]+` separator class for
consistency with the sibling opus/sonnet/haiku regex right below it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: lumingjun <lumingjun@bytedance.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 14:48:50 +08:00
paseo-ai[bot]
7512110e6f fix: update lockfile signatures and Nix hash [skip ci] 2026-06-10 05:21:18 +00:00
Mohamed Boudra
cb37d026ad chore(release): cut 0.1.92 2026-06-10 12:17:09 +07:00
Mohamed Boudra
50ec8955eb Update changelog for 0.1.92 2026-06-10 11:58:30 +07:00
Mohamed Boudra
a3959dd99f Update ACP catalog pins 2026-06-10 11:58:22 +07:00
Mohamed Boudra
27b6242128 Fix first push for new worktrees 2026-06-10 11:54:36 +07:00
Mohamed Boudra
51d1d007ce Import provider sessions as ready agents
Provider listing now returns picker rows only. Importing a selected provider session hydrates its timeline before the daemon publishes the Paseo agent.
2026-06-10 11:29:27 +07:00
Mohamed Boudra
cfd72b815c Update ACP catalog for CodeWhale 2026-06-10 11:29:26 +07:00
Mohamed Boudra
b3f44981a8 Handle ACP providers without MCP support 2026-06-10 11:29:26 +07:00
Bjoern147
e9f9759ba8 Fix Windows Explorer opening Documents instead of the workspace
Closes #476
2026-06-10 12:18:25 +08:00
Bubu
dcbbaa8ece Prefer Windows editor command shims
Refs #1348
2026-06-10 01:04:23 +08:00
Mohamed Boudra
edd5a99832 Update OpenCode command shape test 2026-06-09 18:22:25 +07:00
Mohamed Boudra
52a66cca70 Merge remote-tracking branch 'origin/main' 2026-06-09 18:08:43 +07:00
Mohamed Boudra
e53d26699f Fix removed host routes getting stuck connecting 2026-06-09 18:06:48 +07:00
Mohamed Boudra
c5bcec5c71 Show skill autocomplete anywhere in prompts 2026-06-09 17:15:03 +07:00
Éverton Toffanetto
b83ee957d1 ci: stabilize Electron dependency installs (#1409)
* ci: stabilize Electron dependency installs

Skip unused Electron binary downloads in non-desktop CI jobs and retry Electron installs in desktop tests to avoid external CDN 504 failures during npm ci.

* ci: preserve npm retry exit code

Preserve the final npm ci exit code in the non-Windows Electron retry path so CI diagnostics retain the original failure code.

* ci: preserve retry exit code under errexit

Keep npm ci inside the retry conditional so bash errexit does not bypass retries, while preserving the final npm exit code for diagnostics.
2026-06-09 17:05:11 +08:00
Mohamed Boudra
9f41904c6f Clarify urgent support path 2026-06-09 16:00:03 +07:00
Mohamed Boudra
eca0a5bf67 Let manual update checks bypass rollout
Automatic desktop update checks still respect rollout admission. Manual checks carry an explicit intent through the app and desktop updater service, and the up-to-date state now shows when the last check completed.
2026-06-09 15:47:30 +07:00
Mohamed Boudra
e72b0773e6 Clarify model names in selector 2026-06-09 14:39:26 +07:00
Mohamed Boudra
dcdb178468 Add OMP (Oh My Pi) icon and landing page 2026-06-09 14:03:20 +07:00
Mohamed Boudra
bed8af7aa6 Open file preview markdown links in external browser
FilePreviewMarkdownLink was using Linking.openURL directly, which on
web/Electron performs a plain navigation that opens inside the app
window. Use openExternalUrl instead so desktop builds route through
the preload bridge to shell.openExternal.
2026-06-09 13:28:05 +07:00
Mohamed Boudra
378f1986ac Add real OpenCode import session E2E regression test 2026-06-09 13:09:16 +07:00
Mohamed Boudra
0967557846 Fix native chat anchoring while streaming 2026-06-09 11:45:03 +07:00
Mohamed Boudra
6c3e2bd703 Fix sticky bottom anchoring on initial content growth 2026-06-09 10:46:24 +07:00
Mohamed Boudra
9ce6a38792 Fix mobile composer send button shift 2026-06-09 10:26:08 +07:00
Mohamed Boudra
b832d49a78 Fix iOS review submission bundle 2026-06-09 10:00:33 +07:00
Mohamed Boudra
02ec937399 Inline provider catalog in settings (#1423)
Replace the modal Add Provider flow with an inline Add provider section
below the configured providers list. Makes new providers discoverable
without opening a modal.

- Remove add-provider-modal component
- Add provider-catalog-list component with search + Add button
- Remove duplicate ACP catalog entries for built-in providers
- Add spacing between the two sections
2026-06-09 10:44:37 +08:00
paseo-ai[bot]
0cbb8238c2 fix: update lockfile signatures and Nix hash [skip ci] 2026-06-08 17:03:40 +00:00
Mohamed Boudra
211f5b4141 chore(release): cut 0.1.91 2026-06-08 23:59:19 +07:00
Mohamed Boudra
2293e0965a Add changelog for 0.1.91 2026-06-08 23:43:38 +07:00
Mohamed Boudra
7c68985cc0 Update ACP provider catalog pins 2026-06-08 23:41:50 +07:00
Mohamed Boudra
899849cdd5 Keep daemon worker alive on heartbeat stalls 2026-06-08 23:32:16 +07:00
Mohamed Boudra
409d67ef65 Fix app E2E GitHub harness 2026-06-08 23:03:56 +07:00
Mohamed Boudra
b0954d1616 Log daemon supervisor exit reasons 2026-06-08 23:03:56 +07:00
Mohamed Boudra
9d9f6905c6 Hide OpenCode compact summaries from chat 2026-06-08 23:03:55 +07:00
Mohamed Boudra
b04ff0e6b8 Restore global agent listing (#1420) 2026-06-08 23:53:54 +08:00
qer
a6d0046c97 fix(catalog): point Kimi entry at Kimi Code CLI (#1403)
The ACP catalog kimi entry still referenced the legacy Python kimi-cli. Moonshot's actively maintained agent is now the TypeScript Kimi Code CLI (@moonshot-ai/kimi-code), which exposes the same kimi acp server. Update title, description, version, and install link to match; the launch command is unchanged.
2026-06-08 10:19:31 +00:00
Mohamed Boudra
a1fbc91163 Add OMP as a built-in importable provider (#1388)
* Support imports from Pi-compatible providers

* Add OMP as a built-in provider

* Address Pi session scanner review

* Restore Pi session import discovery

* Update provider test for disabled OMP
2026-06-08 08:17:57 +00:00
Mohamed Boudra
7d748436f4 Add Sponsor page to website 2026-06-08 13:44:24 +07:00
Mohamed Boudra
afbb2e8bb8 Fix release-blocking CI failures 2026-06-08 12:12:40 +07:00
Mohamed Boudra
6524677960 Document checkout-local dev home defaults 2026-06-08 10:34:54 +07:00
Mohamed Boudra
09a1fe46fe Hide OpenCode compaction summaries 2026-06-08 10:14:54 +07:00
Mohamed Boudra
06a8f952db Keep Paseo MCP config out of agent storage
Runtime-injected MCP endpoints can change with daemon listen settings, so compute them for launches instead of persisting them.
2026-06-07 23:31:03 +07:00
Mohamed Boudra
893e3376b0 Recover stale supervised daemons
Teach daemon stop to use lifecycle shutdown when the API is reachable even if the owner pid is stale, then wait for the API to disappear and clean the stale pidfile.

Launch desktop-managed daemons detached from desktop stdio, preserve stale reachable daemon ownership for version checks, and allow desktop stop/restart to use the CLI recovery path.

Add supervisor heartbeats so supervised workers shut down when their supervisor disappears instead of surviving as orphaned reachable daemons.
2026-06-07 23:18:48 +07:00
Mohamed Boudra
7c8b290e2f Stabilize local dev daemon homes
Use a checkout-local .dev/paseo-home for root and worktree dev flows so development daemons do not collide with the packaged app home.

Split server, app, and desktop dev entrypoints, seed worktree homes from the source checkout metadata, and keep desktop dev on its own user-data directory and Expo port.
2026-06-07 23:18:32 +07:00
Mohamed Boudra
d55e1622cb Persist create-agent mode preferences reliably 2026-06-07 15:53:17 +07:00
Mohamed Boudra
69715a77e9 Disable pull and push until the branch diverges 2026-06-07 14:21:32 +07:00
Mohamed Boudra
e1b27fc584 Merge branch 'main' of github.com:getpaseo/paseo 2026-06-07 14:07:45 +07:00
Mohamed Boudra
9c16bc474b Add command center to mobile
Renders as a bottom sheet on compact native instead of returning null.
The hook's window.addEventListener keydown is gated behind isWeb; native
uses TextInput onKeyPress and onSubmitEditing through a shared
handleKeyEvent callback.
2026-06-07 14:07:14 +07:00
chyendongnhanh338
59d48d235a Fix Pi compaction slash commands (#1338)
* fix: handle Pi compact slash command

* fix: handle Pi compaction slash commands

* fix: harden Pi compaction commands

* fix: handle missing Pi autocompaction state

---------

Co-authored-by: X <X@16x.org>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-06-06 22:41:08 +08:00
Mohamed Boudra
6aa73baaab Show useful local speech crash details (#1379)
* Improve local speech worker crash diagnostics

* Fix speech worker stderr tail diagnostics

* Drain speech worker stderr before reporting exit

* Avoid false speech worker crash logs on shutdown
2026-06-06 21:45:33 +08:00
Mohamed Boudra
0cf1717e04 Fix auto-archive after merged PR branch deletion (#1378) 2026-06-06 21:13:16 +08:00
Mohamed Boudra
2822c02543 Keep Codex request identity vanilla (#1377) 2026-06-06 21:00:25 +08:00
Mohamed Boudra
db4376d17a Prepare prompt file attachments for future UI (#1376)
* Support file uploads to agents

* Harden prompt file upload handling

* Fix duplicate upload retry handling

* Update relay reconnect tests for queued messages

* Make upload timeout track idle progress

* Clean failed upload directories
2026-06-06 20:37:38 +08:00
Mohamed Boudra
9a8912b3ef Open browser links in workspace tabs (#1375)
* Open browser window requests as workspace tabs

* Tighten browser new-tab request boundaries
2026-06-06 19:30:21 +08:00
paseo-ai[bot]
fd022bc44b fix: update lockfile signatures and Nix hash [skip ci] 2026-06-05 16:13:31 +00:00
Mohamed Boudra
7408de6300 chore(release): cut 0.1.91-beta.2 2026-06-05 23:08:57 +07:00
Mohamed Boudra
9b21ccd7f1 Report daemon auth failures in status 2026-06-05 23:03:32 +07:00
Mohamed Boudra
20c03355f9 Default git shipping to pull requests 2026-06-05 22:56:33 +07:00
Mohamed Boudra
e09a1591ac Merge branch 'main' of github.com:getpaseo/paseo 2026-06-05 22:39:32 +07:00
Mohamed Boudra
d35bffed8b Allow $schema in config files 2026-06-05 22:38:55 +07:00
dixonl90
d454fa3af4 Fix worktree checkout validation for existing git branch refs (#1358)
* fix(server): allow valid existing worktree branch refs (#1357)

* Fix existing branch checkout validation

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-06-05 23:31:38 +08:00
Mohamed Boudra
59b32ab3be Remove epic orchestration skills 2026-06-05 22:30:53 +07:00
Fabian Fischer
44e9287389 fix(claude): preserve alwaysLoad on MCP server configs (#1333)
* fix(claude): preserve alwaysLoad on MCP server configs

Paseo's Claude provider normalizes McpServerConfig via toClaudeSdkMcpConfig
before passing it to claude-agent-sdk. The function copied type/command/args/
env (stdio) and type/url/headers (http, sse) but dropped the alwaysLoad
field for all three transports.

Without alwaysLoad making it to the SDK, every MCP server's tools were
deferred behind tool search. In practice the agent never discovered them
via search and would respond 'no chrome-devtools tools available' even
with the server fully running and connected.

Add alwaysLoad to the McpServerConfig types and zod schemas, copy it
through in toClaudeSdkMcpConfig for stdio/http/sse, and add unit tests
covering all three transports plus the default-undefined case.

Other providers ignore the field, consistent with the existing pattern
where each provider normalizes only what its CLI/SDK supports.

* test(claude): drop ternary in toClaudeSdkMcpConfig assertion

The 'alwaysLoad' field is now declared on every branch of the
discriminated union returned by toClaudeSdkMcpConfig, so the in-operator
guard is no longer needed. Direct property access expresses the intent
without embedding a conditional in the assertion.

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-06-05 22:29:47 +08:00
Mohamed Boudra
c89177c211 Merge branch 'main' of github.com:getpaseo/paseo 2026-06-05 21:21:17 +07:00
Mohamed Boudra
dd329a4f52 Fix stacked provider settings sheets 2026-06-05 21:15:44 +07:00
Mohamed Boudra
0d1eecc388 Keep virtualenvs out of project picker results (#1356)
* Fix project picker virtualenv suggestions

* Ignore more virtualenv names in project picker
2026-06-05 21:35:47 +08:00
Fabian Fischer
7124a82298 Authenticate file downloads with their capability token alone (#1351)
The daemon's bearer middleware gated every route except /api/health behind the daemon password (PASEO_PASSWORD), including /api/files/download. That route already carries a single-use, 60s-TTL, crypto-random download token that is only ever issued over the authenticated WebSocket, so the token is the capability for the route.

Requiring the daemon password on top of the token broke browser and Electron downloads: those trigger the download via an anchor navigation, which cannot attach an Authorization header. The cross-platform download store (packages/app/src/stores/download-store.ts) therefore sent no bearer, and the daemon returned 401 whenever a password was configured.

Add /api/files/download to the bearer-auth bypass list. The endpoint still rejects requests without a valid token (400 missing / 403 invalid), so it stays authenticated; it just no longer demands the password a second time. This also fixes every already-released client without an app update.
2026-06-05 13:22:30 +00:00
Arielle Ostankova
df635b570a feat(desktop): support multiple windows
Closes #250
2026-06-05 21:08:24 +08:00
Matteo Pietro Dazzi
1377adbece fix(claude): respect profile models for built-in claude provider (#1311)
* fix(claude): respect profile models for built-in claude provider

The built-in Claude provider was the only one in the registry whose
profile `models` array was treated as additive on top of the hardcoded
first-party catalog. Every other built-in provider (and every custom
provider) replaces the runtime model list when `models` is set, which
matches the documented behavior in docs/custom-providers.md.

For users pointing Claude Code at a third-party Anthropic-compatible
gateway (Z.AI, Alibaba/Qwen, MiniMax, custom proxies, …) and curating
the picker with `agents.providers.claude.models`, the old behavior
leaked the nine first-party Claude models into the dropdown, making it
impossible to ship a curated list. This is issue #1299.

The fix flips the built-in Claude entry in `provider-registry.ts` to
match the other providers, so `models` replaces the runtime catalog
(including the settings.json-discovered entries surfaced by
getClaudeModelsWithSettings). The existing `additionalModels` field
keeps its additive semantics for anyone who still wants to append
entries on top of the first-party list.

- provider-registry.ts: drop the claude-only `profileModelsAreAdditive`
  branch and align with the rest of the registry.
- provider-registry.test.ts: update the "append to runtime models"
  expectation for built-in Claude to "replace runtime models" and add a
  regression test that mirrors the issue scenario (hardcoded catalog +
  configured profile models, expect only the profile models).
- agent.test.ts: make the "returns hardcoded claude models" hermetic by
  pointing CLAUDE_CONFIG_DIR at an empty temp dir; the test was reading
  the host's real ~/.claude/settings.json (which now contains MiniMax
  env vars from the issue report) and leaking that into the assertion.
- custom-providers.md: correct the note about Claude profile models
  being additive and document the new replace semantics alongside
  additionalModels.

Closes #1299

* test(claude): drop explanatory comments from new tests

* refactor(claude): inject configDir into ClaudeAgentClient for test hermeticity

Greptile flagged the previous hermeticity fix on agent.test.ts: mutating
process.env.CLAUDE_CONFIG_DIR inside a test leaks the redirection into
any concurrent test in the same process for the duration of the try
block.

Thread a `configDir` option through ClaudeAgentClient ->
getClaudeModelsWithSettings -> readClaudeSettingsModels ->
resolveClaudeConfigDir instead. The constructor follows the same
injection pattern as `resolveBinary`, so the test passes an empty temp
dir via the option and no shared global state is touched.

- models.ts: add an optional `configDir` to
  getClaudeModelsWithSettings, readClaudeSettingsModels, and
  resolveClaudeConfigDir. Env var remains the fallback for production
  callers.
- agent.ts: add `configDir?` to ClaudeAgentClientOptions, store it on
  the instance, and forward it to getClaudeModelsWithSettings from
  listModels.
- agent.test.ts: drop the process.env save/mutate/restore dance and
  pass `configDir: emptyConfigDir` to the constructor instead.

Refs #1311
2026-06-05 20:58:38 +08:00
Kevin Aspesi
350bc08fc4 fix(app): make markdown links tappable on iOS (#1334)
* fix(app): make markdown links tappable on iOS

A markdown link's label renders as nested React elements
(link > textgroup > text -> MarkdownInheritedText spans). The link's
onPress was placed on the wrapping UITextView span, but
react-native-uitextview only attaches onPress to the *string* children
it converts into tappable RNUITextViewChild nodes - element children
pass through untouched, so the handler never reached a node the native
tap recognizer could fire. Link text stayed visible and selectable but
dead to taps; web links never opened.

Thread the link's press handler down to the leaf text spans via a new
AssistantLinkPressProvider so it lands on the real tappable string
nodes. MarkdownInheritedText consumes the context and forwards onPress
to its MarkdownTextSpan; null outside a link, so ordinary text is
unaffected. Gated to iOS - Android forwards onPress through nested
<Text> already, and web uses the <a> path.

* refactor(app): export useAssistantLinkPress from module index

Import it through the assistant-file-links public surface instead of
reaching into the internal link-press-context file, matching the rest of
message.tsx. Addresses Greptile review feedback on #1334.

---------

Co-authored-by: Kevin Aspesi <kevinaspesi@Kevins-Mac-mini.local>
2026-06-05 20:43:09 +08:00
Fabian Fischer
dfddda7969 feat(server): make provider refresh timeout configurable via PASEO_PROVIDER_REFRESH_TIMEOUT_MS (#1346)
* feat(server): make provider refresh timeout configurable

Provider refresh probes (isAvailable + listModels/listModes) were hardcoded
to a 30s timeout. With many providers and MCP servers configured, cold
starts of agents like Copilot can exceed this on the first probe, surfacing
'Timed out refreshing Copilot after 30000ms' even though a manual retry
succeeds (warm caches).

Add a PASEO_PROVIDER_REFRESH_TIMEOUT_MS env var so operators can bump the
ceiling without rebuilding. The explicit constructor option still wins
over the env var, and invalid values fall back to the 30s default.

* refactor(server): address review feedback on refresh timeout config

- Use Number() instead of Number.parseInt() so scientific notation like
  '6e4' parses as 60000 instead of being silently truncated to 6.
- Use vi.stubEnv()/vi.unstubAllEnvs() in tests to match the rest of the
  suite and avoid manual process.env save/restore.
2026-06-05 12:19:05 +00:00
Mohamed Boudra
abf129f56e Fix iOS markdown style metadata warnings
Resolve markdown UITextView styles to plain React Native text styles before crossing the third-party boundary. react-native-uitextview flattens parent and child styles internally, so Unistyles metadata must not be carried into that handoff.
2026-06-05 19:11:29 +07:00
Mohamed Boudra
45cca8a406 Fix iOS markdown image rendering 2026-06-05 14:11:17 +07:00
Mohamed Boudra
e961ceef98 Hide mobile sidebar for new workspace 2026-06-05 10:36:02 +07:00
paseo-ai[bot]
f30f217023 fix: update lockfile signatures and Nix hash [skip ci] 2026-06-04 18:35:24 +00:00
Mohamed Boudra
9d3b037d4f chore(release): cut 0.1.91-beta.1 2026-06-05 01:31:13 +07:00
Mohamed Boudra
c3515d74b9 Fix Windows workspace provider loading (#1329)
* test: cover Windows provider snapshot cwd updates

* fix: normalize Windows provider snapshot paths

* fix: preserve Claude history path candidates

* fix: match Claude project paths on Windows
2026-06-05 02:29:34 +08:00
Matt Cowger
b5192e577a Stop local daemon when removing localhost (#1297)
* fix(app): stop local daemon when removing localhost

* fix(app): handle daemon removal rollback

* fix(app): return listen address in desktop e2e mock

* fix(app): refresh daemon status after registration failure

* fix(app): unslop daemon status error coverage

* fix(app): preserve daemon toggle error context

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-06-04 22:30:40 +08:00
paseo-ai[bot]
9e2c75a8a3 fix: update lockfile signatures and Nix hash [skip ci] 2026-06-04 13:41:28 +00:00
Mohamed Boudra
7048cd5c86 chore(release): cut 0.1.90 2026-06-04 20:37:23 +07:00
Mohamed Boudra
9dbc75e78e Prepare 0.1.90 stable release 2026-06-04 20:36:11 +07:00
Mohamed Boudra
81d973ffc4 Fix diff gutter row alignment 2026-06-04 19:44:45 +07:00
Mohamed Boudra
3d8ef237e3 Fix workspace tab timeline flicker 2026-06-04 19:10:33 +07:00
Mohamed Boudra
74d42cb3d0 Merge branch 'main' of github.com:getpaseo/paseo 2026-06-04 17:51:24 +07:00
Mohamed Boudra
1bca88bd30 Show initial-agent workspaces as Working 2026-06-04 17:49:13 +07:00
Mohamed Boudra
1daa4587e5 Add Dart syntax highlighting (#1326) 2026-06-04 18:26:23 +08:00
Mohamed Boudra
88d44ab2c5 Show free-write answers for OpenCode questions 2026-06-04 15:13:47 +07:00
Mohamed Boudra
3ecf86933f Fix OpenCode child auto accept inheritance 2026-06-04 15:12:12 +07:00
Mohamed Boudra
1b350ac09e Lock skills update status invariant 2026-06-04 15:08:28 +07:00
Mohamed Boudra
de9d796567 Merge branch 'main' of github.com:getpaseo/paseo 2026-06-04 14:23:00 +07:00
Mohamed Boudra
cd8e0a5e08 Fix sidebar selection and Unistyles warnings
Forward selection state into native draggable lists so virtualized workspace rows update when the active route changes.

Keep floating caller styles array-shaped and strip Unistyles metadata from owned inline geometry to avoid runtime style warnings.
2026-06-04 14:17:08 +07:00
Mohamed Boudra
38f8179bc1 Add a global New workspace picker (#1324)
* Add global New workspace project picker

* Clean up New workspace picker state
2026-06-04 15:16:41 +08:00
Mohamed Boudra
0b62e3bd7f Merge branch 'main' of github.com:getpaseo/paseo 2026-06-04 13:38:54 +07:00
Mohamed Boudra
f0370a3c20 Fix long dictation retry backpressure
Treat Node IPC backpressure as a queue signal instead of a failed send, and pace buffered dictation replay so finish waits for the backlog to flush.
2026-06-04 13:35:10 +07:00
Mohamed Boudra
e381117b1c Limit OpenCode metadata refresh concurrency 2026-06-04 13:32:51 +07:00
Mohamed Boudra
f9791d7ab6 Fix mobile sidebar swipes under load
Move mobile sidebar gesture gating to shared visual panel state so swipes can open and close sidebars before React state catches up. Keep the sidebar list scroll simultaneous with the close pan so vertical scrolling stays immediate.
2026-06-04 13:32:15 +07:00
Mohamed Boudra
66a5bd8a88 Fix sidebar workspace row alignment 2026-06-04 13:26:45 +07:00
Mohamed Boudra
de5fa4dc36 Publish beta npm packages under beta tag 2026-06-04 11:53:58 +07:00
Mohamed Boudra
6dcf3ffd6a Keep archived workspaces recent in Done 2026-06-04 11:46:04 +07:00
Mohamed Boudra
f139966554 Fix focus mode tab padding 2026-06-04 11:08:37 +07:00
Mohamed Boudra
b0c4cc99ed Fix settings host picker navigation 2026-06-04 09:37:14 +07:00
Mohamed Boudra
3e3e944cf4 Fix compact sheet bottom spacing 2026-06-04 09:28:05 +07:00
Mohamed Boudra
f3b7dac768 Fix compact provider sheet footer 2026-06-04 08:46:47 +07:00
paseo-ai[bot]
b9f3e59d48 fix: update lockfile signatures and Nix hash [skip ci] 2026-06-03 18:06:46 +00:00
Mohamed Boudra
801140abb2 chore(release): cut 0.1.90-beta.1 2026-06-04 01:02:54 +07:00
Mohamed Boudra
b345ef1242 Stop counting initializing agents as running 2026-06-04 00:53:38 +07:00
Mohamed Boudra
f6c0b60b7c Fix sidebar shortcut hints shifting rows 2026-06-04 00:48:53 +07:00
Mohamed Boudra
ffe4d4046c Update ACP provider catalog pins 2026-06-04 00:27:46 +07:00
Mohamed Boudra
7f9582e197 Merge branch 'main' of github.com:getpaseo/paseo 2026-06-04 00:22:40 +07:00
Mohamed Boudra
6e8b7aaa07 Show PR action when auto-merge is enabled 2026-06-04 00:21:33 +07:00
Mohamed Boudra
31ae545289 Group and clear workspace status in the sidebar (#1317)
* Add status grouping to the workspace sidebar

* Mark workspaces as read from the sidebar

* Clean package builds before release checks

* Fix sidebar status follow-up checks
2026-06-04 01:20:20 +08:00
Mohamed Boudra
71ce96b913 Prioritize ready actions and scheduled agent prompts (#1316)
* Sort sessions by last activity

* Prioritize shipping actions in git controls

* Fix scheduled new agent titles
2026-06-03 16:16:05 +00:00
Mohamed Boudra
eba8de6ab5 Speed up session import 2026-06-03 23:09:45 +07:00
Mohamed Boudra
4c730611e6 Fix markdown code rendering in file previews 2026-06-03 23:08:47 +07:00
Mohamed Boudra
bf81496a4f Merge branch 'main' of github.com:getpaseo/paseo 2026-06-03 23:00:52 +07:00
Mohamed Boudra
6d29092fe6 Fix older timeline loading after catch-up 2026-06-03 22:32:12 +07:00
Mohamed Boudra
b3ba1572b3 Keep child agents unattended across providers (#1315)
* Keep child agents unattended across providers

* Clean up unattended create config resolution

* Make provider catalog tests tolerate narrow CI fixtures
2026-06-03 23:17:14 +08:00
Mohamed Boudra
369adced52 Archive merged PR workspaces from settings (#1313)
* Add workspace settings for merged PR cleanup

* Fix worktree branch ahead status

* Handle PR worktree upstream status

* Remove stale checkout tracking field

* Update settings E2E host section slugs

* Treat unknown upstream as unsafe for auto-archive
2026-06-03 23:17:08 +08:00
Mohamed Boudra
b510244518 Prioritize shipping actions in git controls 2026-06-03 22:12:27 +07:00
Mohamed Boudra
c44ad7ecd1 Sort sessions by last activity 2026-06-03 22:00:52 +07:00
Mohamed Boudra
bd4889e243 Merge branch 'main' of github.com:getpaseo/paseo 2026-06-03 19:34:57 +07:00
Mohamed Boudra
3634f5e2d8 Fix compact composer flash on tab switch 2026-06-03 19:32:49 +07:00
Mohamed Boudra
76ae8c19b0 Show delegated agent activity on parent workspaces 2026-06-03 19:10:05 +07:00
Mohamed Boudra
91ef9301c2 Show question prompts one at a time
* Show question prompts one at a time

* Make question prompt submit action literal
2026-06-03 19:49:36 +08:00
Mohamed Boudra
510a299420 Update epic PR babysitting guidance 2026-06-03 18:33:13 +07:00
Mohamed Boudra
38d690e425 Show attachment actions in a mobile sheet 2026-06-03 18:30:43 +07:00
Aaron Zhong
e9a746d4ea feat: open active files in editors and file managers (#1285)
* feat: open active files in editors and file managers

* fix: open the workspace folder alongside the active file in editors

Editor targets received only the file path, so opening from the workspace
file explorer launched the bare file without the surrounding project. Thread
an optional workspace `cwd` through the open_in_editor request so editors
launch as `<workspace> <file>`, loading the project explorer plus the file.
File-manager targets keep their single-path behavior.

* fix: address PR review feedback on file-open path resolution

- Narrow the tilde guard so only genuine home-relative paths (~ or ~/…)
  are rejected; filenames like ~env.ts are now treated as workspace-relative.
- Make the workspace-containment check case-insensitive for Windows drive
  paths so case-only differences still resolve a relative path.
- Centralize the file-manager target id set in the protocol
  (isFileManagerTargetId) so the server and app no longer duplicate it.

* feat(desktop): open workspace files from Electron

* test(desktop): avoid mocked Electron editor IPC

* fix(desktop): avoid Windows shell for editor executables

* test(app): mock desktop editor bridge in e2e

* fix(app): reuse resolved workspace open target path

* fix(app): stabilize active file memo deps

* fix(protocol): tag legacy editor RPC shims

* fix(desktop): allow custom editor target ids

* fix(desktop): open Windows editor paths with metacharacters

Quote metacharacter values for .cmd editor wrappers so cmd.exe preserves them across wrapper execution instead of passing literal carets through.

* fix(app): preserve reviewed file path handling

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-06-03 19:20:30 +08:00
Mohamed Boudra
c0a0e3a1c6 Keep desktop skills up to date automatically (#1309)
* Keep installed desktop skills up to date

* Preserve user files during skill sync
2026-06-03 11:09:02 +00:00
Mohamed Boudra
3681fd132b Open workspace files in desktop targets 2026-06-03 18:34:24 +08:00
Mohamed Boudra
bb18cf618a Write persisted files atomically 2026-06-03 17:25:07 +07:00
Mohamed Boudra
fbaa0caedb Persist submitted ACP user messages 2026-06-03 17:22:53 +07:00
Mohamed Boudra
45f412456c Improve switch contrast in zinc theme 2026-06-03 16:02:55 +07:00
Mohamed Boudra
9f67542bd5 Keep keyboard dismissed for selector sheets 2026-06-03 15:53:41 +07:00
Mohamed Boudra
b4fd965c43 Dismiss keyboard when opening selectors 2026-06-03 15:27:51 +07:00
Mohamed Boudra
b365d818f3 Merge branch 'main' of github.com:getpaseo/paseo 2026-06-03 15:17:03 +07:00
Mohamed Boudra
6f6c939624 Pin ACP catalog entries to current versions 2026-06-03 14:08:56 +07:00
paseo-ai[bot]
0d98df4a00 fix: update lockfile signatures and Nix hash [skip ci] 2026-06-02 13:55:29 +00:00
Mohamed Boudra
5aecd36194 chore(release): cut 0.1.89 2026-06-02 20:51:07 +07:00
Mohamed Boudra
539441b3d5 Add 0.1.89 changelog 2026-06-02 20:49:48 +07:00
Mohamed Boudra
4b9eff1759 Merge branch 'main' of github.com:getpaseo/paseo 2026-06-02 20:31:09 +07:00
Mohamed Boudra
48bc2f3166 Bump Cline ACP catalog entry to v3 2026-06-02 20:30:51 +07:00
Mohamed Boudra
14af053c66 Keep delegated agents out of workspace alerts (#1293)
* Keep delegated agents out of workspace alerts

* Share delegated agent label parsing
2026-06-02 20:55:19 +08:00
Mohamed Boudra
4f3116d28a Unify workspace service URLs (#1288)
* Unify workspace service proxying

* Clean up service proxy review issues
2026-06-02 19:49:58 +08:00
Mohamed Boudra
5666b3014a Fix Pi extension command turns (#1290) 2026-06-02 10:09:50 +00:00
Mohamed Boudra
07ace19b69 Keep fork PR badges visible in worktrees 2026-06-02 15:38:27 +07:00
Matt Cowger
f1a20a9fe9 Add configurable service proxy (#1280)
* Add configurable service proxy

* Fix service proxy URL used when opening scripts with a public base URL

When a public proxy URL was configured, the View link was falling through
to direct connection logic and opening the wrong URL. Move the public URL
early-return before connection-type checks, and remove the now-dead
duplicate check further down.

* Add service proxy documentation

Covers config options, URL generation format, wildcard DNS setup,
and reverse proxy Host header requirements.

* Cap combined public service label and document hostname format

* Address service proxy review feedback
2026-06-02 14:08:41 +08:00
Mohamed Boudra
eac5d93f78 Tune hero mockup sidebar, composer, and tabs 2026-06-01 23:39:02 +07:00
Mohamed Boudra
541e4c04cf Lower compact composer breakpoint to 500, add footer bottom padding 2026-06-01 22:29:58 +07:00
Mohamed Boudra
ce8ad8c68f Let composer controls adapt to narrow panes (#1270) 2026-06-01 22:58:03 +08:00
Éverton Toffanetto
1ede1bccf7 feat(desktop): persist and restore window state
Fixes #1223
2026-06-01 12:04:58 +00:00
Mohamed Boudra
a5c2f39f65 Tighten social proof marquee spacing 2026-06-01 18:04:20 +07:00
Mohamed Boudra
a807eb0eb2 Update homepage messaging and carousel performance 2026-06-01 16:30:17 +07:00
Matt Cowger
d406c29e16 Add configurable worktree root
* Add configurable worktree root

Allow new Paseo worktrees to be created under a custom worktrees.root from config.json while preserving the existing PASEO_HOME/worktrees default. Thread the resolved root through create/list/archive/ownership flows, MCP/session paths, checkout metadata, schema, docs, and targeted tests.

* Address worktree root review feedback

Use the resolved Paseo worktree base when a custom PASEO_HOME is provided, and remove an unused worktreesRoot dependency from worktree list handling.

* Fix checkout worktree path test on Windows

Use platform-native paths in the custom PASEO_HOME regression test so Windows CI compares against the same resolved root shape as production code.

* Clarify optional worktrees config docs

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-06-01 16:19:29 +08:00
Geoffrey Marc
8c6abcb41f fix(app): render bold/italic/strikethrough and line breaks via UITextView span on iOS (#1254)
* fix(app): render bold/italic/strikethrough via UITextView span on iOS

strong/em/s had no custom render rule and fell back to
react-native-markdown-display's defaults, which wrap children in a plain
RN <Text>. On iOS the paragraph/textgroup are native UITextViews
(markdown-text.ios.tsx); a plain <Text> nested inside is not hoisted into
a UITextViewChild, so its content rendered invisibly — bold/italic/
strikethrough silently disappeared (regression from #1153, v0.1.84).

Route strong/em/s through MarkdownInheritedText -> MarkdownTextSpan, the
same path text/textgroup already use, so the styled run composes as a
UITextViewChild on iOS and stays visible + selectable. Web/Android use a
plain <Text> for MarkdownTextSpan, so behavior there is unchanged.

Fixes #1253

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(app): render hard/soft line breaks via UITextView span on iOS

hardbreak/softbreak had no custom rule, so react-native-markdown-display
emitted a plain RN <Text>{"\n"}. Inside the paragraph UITextView that
plain <Text> is dropped (same root cause as strong/em/s/link): on iOS
hard line breaks vanished, and a softbreak between words joined them
("one\ntwo" -> "onetwo") — the "adjacent words dropped" symptom from the
original report.

Route both through MarkdownTextSpan so the newline composes as a
UITextViewChild on iOS; web/Android keep the same "\n".

Refs #1253

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Geoffrey <geoffrey.marc@consulting-for.edenred.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 15:57:06 +08:00
Matt Cowger
b8ccd543c4 fix: archive worktrees even when teardown fails
* Archive workspace record even if teardown fails

* Fix teardown test for cross-platform commands
2026-06-01 15:34:51 +08:00
Éverton Toffanetto
ae5dfc0b3c Fix split-pane right-edge resize clipping (#1261)
* fix(app): clamp split pane resize

Prevent right-edge drags from overflowing the Electron viewport.

* fix(app): isolate resize pointer state

Keep concurrent resize drags from clobbering each other.
2026-06-01 15:32:26 +08:00
Mohamed Boudra
539d2969f1 Add homepage social proof 2026-06-01 14:06:12 +07:00
Mohamed Boudra
7fb3dda20c Add detached agents and heartbeat scheduling (#1266) 2026-06-01 14:07:31 +08:00
paseo-ai[bot]
89ec358d00 fix: update lockfile signatures and Nix hash [skip ci] 2026-06-01 04:31:01 +00:00
Mohamed Boudra
0efa0f3c42 chore(release): cut 0.1.88 2026-06-01 11:26:48 +07:00
Mohamed Boudra
13a1d5ffba Update changelog date for 0.1.88 2026-06-01 11:23:45 +07:00
Mohamed Boudra
f9f6ff2dbc Restore canonical timeline fetches
Older clients still request canonical rows for catch-up. Honor that explicit request while leaving projected as the default and projected app sync path.

Update the pending 0.1.88 changelog with the fixes already on main.
2026-06-01 11:17:19 +07:00
fireblue
3095bcb760 fix(desktop): stop pinning macOS displays at max refresh rate (#1242)
The macOS compositor watchdog called webContents.setBackgroundThrottling(false)
unconditionally for the window's whole lifetime. That disables Chromium's idle
frame-rate throttling, so the GPU process's CADisplayLink never idles — pinning
ProMotion panels at their maximum refresh rate (120Hz) continuously, even while
the app is idle or occluded, which drains the battery.

The call was never needed: the probe already skips non-producing windows via its
screen-lock / isVisible / isMinimized / document.visibilityState guards, and the
freeze it targets happens while the window is visible and focused, where
background throttling never applies. Removing it lets the compositor idle and the
display drop to a low adaptive rate when nothing animates; freeze detection is
unaffected. A comment and a docs note keep it from being re-added.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 11:42:22 +08:00
Mohamed Boudra
eb7495d231 Use Fastlane deliver for review resubmission 2026-06-01 10:38:20 +07:00
Mohamed Boudra
357c31d16e Clarify mobile release completion checks 2026-06-01 10:30:39 +07:00
Geoffrey Marc
a3b0af9ac0 fix(app): render inline links via UITextView span on iOS (#1257)
AssistantMarkdownLink's native branch wrapped its children in a plain RN
<Text>. On iOS that <Text> sits inside the paragraph's native UITextView
and is not hoisted into a UITextViewChild, so link text (markdown links,
bare URLs, and inline-code path links) was silently dropped — the same
root cause as the bold/italic/strikethrough fix in this PR.

Route the native link through MarkdownTextSpan so it composes as a
UITextViewChild and stays visible + selectable. MarkdownTextSpan now
forwards onPress/accessibilityRole across platforms: tappable on
Android/web; on iOS react-native-uitextview nulls onPress on the root
native view, so reliable tap-to-open is still tracked by #21 — but
visible, selectable, copyable link text strictly beats an invisible one.

Refs #1253

Co-authored-by: Geoffrey <geoffrey.marc@consulting-for.edenred.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 11:29:59 +08:00
Daniel Salmerón Amselem
632c48fde3 Add timezone-aware cron schedules (#1232)
* feat: add timezone-aware cron schedules

* chore: normalize schedule timezone property

* fix: reject blank schedule timezone in mcp

* Stabilize terminal resize browser repro

* Fix terminal resize repro row assertions

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-06-01 00:32:45 +08:00
Mohamed Boudra
922a93af2f Serialize browser app tests 2026-05-31 22:42:25 +07:00
Mohamed Boudra
2227540fad Show context usage beside compact mode selector 2026-05-31 22:38:46 +07:00
Mohamed Boudra
360110accc Stabilize terminal resize browser repro 2026-05-31 22:27:07 +07:00
Mohamed Boudra
6260224514 Update changelog for 0.1.88 2026-05-31 22:05:25 +07:00
Mohamed Boudra
1821f2853a Fix release prep checks 2026-05-31 22:05:18 +07:00
paseo-ai[bot]
c60bc9f5ae fix: update lockfile signatures and Nix hash [skip ci] 2026-05-31 14:45:49 +00:00
Mohamed Boudra
9f88b4e69c Refine appearance settings and syntax highlighting
Keep parsed chat and tool detail content in sync with appearance token changes. Hide font-family controls on native, use One as the default syntax theme, add Swift highlighting, and give Nord a distinct light palette.
2026-05-31 21:40:38 +07:00
Mohamed Boudra
4cc72a84e8 Fix terminal snapshots reflowing after resize
Terminal snapshots now carry soft-wrap row metadata only to clients that advertise support, so restored output can resize like live output without breaking older clients.
2026-05-31 19:40:15 +07:00
Mohamed Boudra
794cbcef63 Fix scripts menu resizing after service launch
Reanimated entering animations can leave the measured menu surface with an inline height. Release that fixed height after opening and when measured content changes so service rows can grow in place.
2026-05-31 19:40:15 +07:00
Mohamed Boudra
a1c8e1a1f9 Add appearance settings for theme, fonts, and syntax highlighting (#1236)
* Add appearance settings for theme, fonts, and syntax highlighting

New Appearance section in Settings to configure the app theme, UI and
mono font family + size, and a syntax-highlighting theme chosen
independently of light/dark. Font family fields default to empty and
show the active system stack as the placeholder; a live split-diff
preview reflects the choices as they change. The theme picker moves
here from General.

Preferences are client-only (AsyncStorage) and applied at runtime by
patching every registered Unistyles theme via
UnistylesRuntime.updateTheme, so existing StyleSheet consumers repaint
with no per-component preference reads and no useUnistyles. No
daemon/protocol change.

* Expand syntax themes and move the preview under the chooser

Replace the small fixed syntax-theme set with eight popular themes — GitHub,
Catppuccin, Dracula, Tokyo Night, One, Nord, Gruvbox, Solarized — built from a
compact per-theme role palette. The app theme and syntax theme stay separate;
the only coupling is the light/dark axis: a theme with both variants uses its
light palette on a light app and its dark palette on a dark app, while the code
frame (gutter, line numbers, background) keeps following the app theme. Default
is now GitHub (GitHub keeps its existing hand-tuned maps for an exact match).

Move the live preview directly under the syntax-theme chooser, drop the
"preview.ts" filename header, and use a clearer code snippet for it.

* Update settings e2e for the moved theme picker and Appearance section

The theme picker moved from General to the new Appearance section, so the
General-content assertion no longer finds "Theme" — point it at "Default send"
(which stays in General) and add Appearance to the e2e section map plus an
expectAppearanceContent check, and exercise the Appearance section in the
sidebar-navigation test.

* Render the appearance preview as a unified diff

Switch the live preview from side-by-side columns to a single unified diff:
unchanged context lines plus "-"/"+" rows for the change, matching how diffs
read elsewhere in the app. Same snippet, mono font, syntax colors, and live
code-font draft behavior.

* Fix code font size change shrinking all UI text

applyAppearance scaled the live (already-patched) theme ramp instead of the
authored FONT_SIZE ramp, so it was not idempotent: every appearance change
re-scaled the current sizes, compounding the whole-UI font scale. With any
non-default UI size, changing the code size — or any setting — shrank (or grew)
all app text, and it persisted into the theme. Build the ramp from the canonical
FONT_SIZE ramp each apply (code size still set absolutely), so applies are
idempotent and a code-size change never touches the UI ramp.

* Apply the interface font across the app on web

react-native-web stamps a default font onto every text element, so the interface
font only reached the few components that set a font explicitly. Inject one rule
that points all text at a CSS variable (updated live as the setting changes),
with high specificity (1,2,0) so it deterministically beats RN-web's base font
and Unistyles' generated classes — no stylesheet-order fragility. Code, diff, and
monospace surfaces carry a `data-pmono` marker (and their subtrees are excluded),
so they keep their code font.

Web-only: React Native has no global font cascade, so native still applies the UI
font only where components opt in. Inline `code` within chat prose is a known
minor gap (its render paths aren't tagged yet).

* Keep preview code in the code font

* Apply code font settings to terminals

* Fix settings host picker import after rebase

* Format terminal webview bundle
2026-05-31 16:09:01 +08:00
Mohamed Boudra
f26a81798b Make workspace tab switching faster (#1251)
* Avoid workspace shell renders when toggling sidebar

* Avoid rerendering inactive workspace screens

* Avoid rerendering unchanged sidebar rows

* Keep sidebar selection state in the list

* Speed up workspace tab switching
2026-05-31 15:46:17 +08:00
Mohamed Boudra
67133e3ebc Fix terminal resize after splitting panes (#1249) 2026-05-31 15:42:39 +08:00
Mohamed Boudra
d1f37b5d72 Route real provider tests through OpenRouter 2026-05-31 13:57:17 +07:00
Mohamed Boudra
8cf80c2486 Mark oversized generated diffs as too large
Gate tracked diff sections by content size before parsing and highlighting while preserving the existing batched git diff path.
2026-05-31 13:57:17 +07:00
Mohamed Boudra
3d96ea6354 Fix chat history paging around tool updates (#1247)
* Fix projected timeline paging

* Avoid full timeline fetches for plain pages
2026-05-31 05:38:29 +00:00
Mohamed Boudra
0e3eb9cf75 Flatten settings sidebar with a host picker (#1241)
* Flatten settings sidebar with a host picker

Settings had two implicit tiers: app-level sections as flat sidebar rows,
and per-host settings hidden behind a drill-in to one monolithic host page.
This flattens both into a single sidebar with an "App" group and a "Host"
group, where the host's settings are top-level rows scoped by a host picker.

- Split the monolithic HostPage into four section pages: Connections,
  Orchestration (was the unnamed inject-tools + system-prompt block),
  Providers, and Daemon (status, updates, restart, remove).
- Host picker reuses the canonical <Combobox> sidebar host switcher; defaults
  to the local host so a single-local-host user never thinks about "the daemon".
- Host sections become real sub-routes (/settings/hosts/[id]/[section]); the
  old /settings/hosts/[id] redirects to the connections section, so existing
  deep links (pairing, workspace, open-project) keep working.
- "Open providers" from the project screen now lands on the Providers section.

* Fix e2e host-settings navigation for the flat sidebar

The flat-settings refactor moved host settings onto separate section pages,
but three e2e helpers still expected the old monolithic host page:

- openDesktopSettings: the daemon-lifecycle card now lives on the Daemon
  section — navigate there before asserting it.
- acp catalog: "Add provider" now lives on the Providers section — open that
  section before opening the add-provider modal.
- openAddHostFlow: "Add host" is now an item inside the host picker (Combobox),
  not a standalone button — open the picker first.

Test-only; no product changes. Fixes the 8 playwright failures on this branch.
2026-05-31 12:55:08 +08:00
Mohamed Boudra
c8ca503cc3 Show agent terminals created in workspace subdirectories (#1245)
Terminals were bucketed and looked up by exact cwd, so a terminal an
agent opened in a subdirectory of the workspace never appeared in the
workspace panel and its live updates were dropped — the panel only ever
queried and subscribed the exact root.

getTerminals(root) now aggregates every bucket at or below the root, and
the session controller delivers a subdirectory change to each subscribed
root above it, keyed by that root and carrying the full aggregated list.
Keying by the root with the complete set is what keeps the client's
cache replacement from dropping the terminals that live at the root.
2026-05-31 12:29:05 +08:00
2147 changed files with 315628 additions and 60071 deletions

View File

@@ -1,6 +1,6 @@
---
name: release-beta
description: Cut a beta release of Paseo. Use when the user says "release beta", "cut a beta", "ship a beta", "beta release", or "/release-beta". Betas are silent release candidates — no changelog, no website move.
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.
user-invocable: true
---
@@ -8,4 +8,6 @@ user-invocable: true
Read `docs/release.md` in the Paseo repo and follow the **Beta flow** section end-to-end. Run the **Beta release** completion checklist at the bottom of that doc.
Key rule the doc enforces — betas don't touch `CHANGELOG.md`. Don't draft release notes.
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.

View File

@@ -1,11 +1,13 @@
---
name: release-stable
description: Cut a stable release of Paseo (fresh patch or promote from beta). Use when the user says "release stable", "ship stable", "promote", "release:patch", "release:promote", or "/release-stable".
description: Cut a stable release of Paseo (fresh patch or minor, or promote from beta). Use when the user says "release stable", "ship stable", "promote", "release:patch", "release:minor", "release:promote", or "/release-stable".
user-invocable: true
---
# Release stable
Read `docs/release.md` in the Paseo repo and follow the **Standard release (patch)** flow if cutting fresh, or the **Beta flow** promotion step if promoting an existing beta. Run the **Stable release (or promotion)** completion checklist at the bottom of that doc.
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.
The doc covers the changelog (required for stable), the pre-release sanity check (required for stable), and the post-release babysit pattern. Don't skip steps.
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.

41
.dockerignore Normal file
View File

@@ -0,0 +1,41 @@
.git
.debug.conversations
.debug
.dev
.playwright-mcp
**/.playwright-mcp
.paseo
**/.paseo-provider-history
.plans
.tasks
.valknut
.claude/settings.local.json
**/.claude/settings.local.json
.claude/scheduled_tasks.lock
.claude/worktrees
.wrangler
**/.wrangler
**/.tanstack
PLAN.md
valknut-report.html
valknut-report.json
.env*
**/.env*
.dev.vars
**/.dev.vars
*.pem
**/*.pem
**/.secrets
**/node_modules
**/dist
**/build
**/.cache
**/.expo
**/test-results
**/*.tsbuildinfo
artifacts
packages/app/android
packages/desktop/release
plan.*.log
*.log
CLAUDE.local.md

View File

@@ -6,6 +6,8 @@ body:
- type: markdown
attributes:
value: |
I'm a solo maintainer and don't always keep up with GitHub Issues daily. If something is urgent or blocking you, [Discord](https://discord.gg/jz8T2uahpH) is the fastest place to reach me.
Before opening, please:
- search existing issues for the same symptom

View File

@@ -2,4 +2,4 @@ blank_issues_enabled: false
contact_links:
- name: Discord
url: https://discord.gg/jz8T2uahpH
about: Quick questions, sharing a video of a bug, or anything that's better as a chat. A lot of issues start better here.
about: Urgent or blocking issues, quick questions, sharing a video of a bug, or anything that's better as a chat.

View File

@@ -71,7 +71,7 @@ jobs:
scope: "@boudra"
- name: Install JS dependencies
run: npm ci
run: node scripts/npm-retry.mjs ci
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -20,6 +20,10 @@ env:
jobs:
format:
runs-on: ubuntu-latest
env:
# This job never executes Electron. Skipping the hosted binary avoids
# unrelated npm ci failures when Electron's CDN returns 504.
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
steps:
- uses: actions/checkout@v4
@@ -29,13 +33,15 @@ jobs:
cache: "npm"
- name: Install dependencies
run: npm ci
run: node scripts/npm-retry.mjs ci
- name: Check formatting
run: npx oxfmt --check .
lint:
runs-on: ubuntu-latest
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
steps:
- uses: actions/checkout@v4
@@ -45,7 +51,7 @@ jobs:
cache: "npm"
- name: Install dependencies
run: npm ci
run: node scripts/npm-retry.mjs ci
- name: Lint lockfile
run: npx --yes lockfile-lint --path package-lock.json --type npm --allowed-hosts npm --validate-https --validate-integrity
@@ -58,6 +64,8 @@ jobs:
typecheck:
runs-on: ubuntu-latest
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
steps:
- uses: actions/checkout@v4
@@ -67,8 +75,7 @@ jobs:
cache: "npm"
- name: Install dependencies
run: npm ci
run: node scripts/npm-retry.mjs ci
- name: Build server stack
run: npm run build:server
@@ -88,6 +95,8 @@ jobs:
os: [ubuntu-latest, windows-latest]
runs-on: ${{ matrix.os }}
name: server-tests (${{ matrix.os }})
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
steps:
- uses: actions/checkout@v4
with:
@@ -102,10 +111,9 @@ jobs:
run: git fetch --no-tags origin main:refs/remotes/origin/main
- name: Install dependencies
run: npm ci
run: node scripts/npm-retry.mjs ci
- name: Install agent CLIs for provider tests
run: npm install -g @anthropic-ai/claude-code opencode-ai
run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code opencode-ai
- name: Build server dependencies
run: npm run build:server-deps
@@ -123,25 +131,78 @@ jobs:
matrix:
os: [ubuntu-latest, windows-latest]
runs-on: ${{ matrix.os }}
timeout-minutes: 30
permissions:
contents: read
pull-requests: read
steps:
- uses: actions/checkout@v4
- name: Detect desktop changes
if: matrix.os == 'ubuntu-latest'
id: desktop_changes
uses: dorny/paths-filter@v3
with:
filters: |
desktop:
- 'packages/desktop/**'
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Install dependencies with retry
run: node scripts/npm-retry.mjs ci
- name: Build server stack
run: npm run build:server
- name: Run desktop tests
run: npm run test --workspace=@getpaseo/desktop
app-tests:
runs-on: ubuntu-latest
- name: Build app dependencies for desktop E2E
if: matrix.os == 'ubuntu-latest'
run: npm run build:app-deps
- name: Install virtual display
if: matrix.os == 'ubuntu-latest'
run: sudo apt-get update && sudo apt-get install -y xvfb xauth
- name: Run real Electron browser tab bridge E2E
if: matrix.os == 'ubuntu-latest'
run: npm run test:e2e:browser-tab-bridge --workspace=@getpaseo/desktop
env:
PASEO_TAB_BRIDGE_E2E_ARTIFACT_DIR: ${{ runner.temp }}/browser-tab-bridge-e2e
- name: Upload browser tab bridge diagnostics
uses: actions/upload-artifact@v4
if: failure() && matrix.os == 'ubuntu-latest'
with:
name: browser-tab-bridge-e2e
path: ${{ runner.temp }}/browser-tab-bridge-e2e
if-no-files-found: ignore
retention-days: 7
- name: Build and smoke unpacked desktop app
if: matrix.os == 'ubuntu-latest' && steps.desktop_changes.outputs.desktop == 'true'
run: npm run build:desktop -- --publish never --linux --x64 --dir
env:
EP_GH_IGNORE_TIME: true
PASEO_DESKTOP_SMOKE: "1"
PASEO_DESKTOP_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-smoke
- name: Upload packaged smoke diagnostics
if: failure() && matrix.os == 'ubuntu-latest' && steps.desktop_changes.outputs.desktop == 'true'
uses: actions/upload-artifact@v4
with:
name: desktop-packaged-smoke-linux-x64
path: ${{ runner.temp }}/desktop-smoke
if-no-files-found: ignore
retention-days: 7
import-electron:
runs-on: macos-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
@@ -150,11 +211,47 @@ jobs:
node-version: "22"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Install dependencies with retry
run: node scripts/npm-retry.mjs ci
- name: Build app and server dependencies
run: |
npm run build:app-deps
npm run build:server
- name: Build Desktop main
run: npm run build:main --workspace=@getpaseo/desktop
- name: Run product import behavior
run: npm run test:import-electron --workspace=@getpaseo/desktop
- name: Upload import behavior diagnostics
if: failure()
uses: actions/upload-artifact@v4
with:
name: import-electron-macos
path: |
packages/app/test-results/
packages/app/playwright-report/
retention-days: 7
app-tests:
runs-on: ubuntu-latest
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
- name: Install dependencies with retry
run: node scripts/npm-retry.mjs ci
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
timeout-minutes: 10
run: npx playwright install chromium
- name: Build app dependencies
run: npm run build:app-deps
@@ -164,6 +261,8 @@ jobs:
sdk-tests:
runs-on: ubuntu-latest
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
steps:
- uses: actions/checkout@v4
@@ -173,8 +272,7 @@ jobs:
cache: "npm"
- name: Install dependencies
run: npm ci
run: node scripts/npm-retry.mjs ci
- name: Build client dependencies
run: npm run build:client
@@ -188,7 +286,19 @@ jobs:
run: npm run typecheck:examples --workspace=@getpaseo/client
playwright:
strategy:
fail-fast: false
matrix:
include:
- { label: "shard 1/4", shard: 1, desktop: false }
- { label: "shard 2/4", shard: 2, desktop: false }
- { label: "shard 3/4", shard: 3, desktop: false }
- { label: "shard 4/4", shard: 4, desktop: false }
- { label: "desktop overlay", shard: "desktop", desktop: true }
name: playwright (${{ matrix.label }})
runs-on: ubuntu-latest
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
steps:
- uses: actions/checkout@v4
@@ -197,11 +307,11 @@ jobs:
node-version: "22"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Install dependencies with retry
run: node scripts/npm-retry.mjs ci
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
timeout-minutes: 10
run: npx playwright install chromium
- name: Build app dependencies
run: npm run build:app-deps
@@ -210,18 +320,24 @@ jobs:
run: npm run build:server
- name: Install agent CLIs for provider tests
run: npm install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
if: ${{ !matrix.desktop }}
run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
- name: Run Playwright E2E tests
run: npm run test:e2e --workspace=@getpaseo/app
if: ${{ !matrix.desktop }}
run: npm run test:e2e --workspace=@getpaseo/app -- --shard=${{ matrix.shard }}/4
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
- name: Run desktop-overlay Playwright tests
if: ${{ matrix.desktop }}
run: npm run test:e2e:desktop --workspace=@getpaseo/app
- name: Upload test artifacts
uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-results
name: playwright-results-${{ matrix.shard }}
path: |
packages/app/test-results/
packages/app/playwright-report/
@@ -229,6 +345,8 @@ jobs:
relay-tests:
runs-on: ubuntu-latest
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
steps:
- uses: actions/checkout@v4
@@ -238,7 +356,7 @@ jobs:
cache: "npm"
- name: Install dependencies
run: npm ci
run: node scripts/npm-retry.mjs ci
- name: Build relay
run: npm run build:relay
@@ -253,6 +371,8 @@ jobs:
shard: [1, 2, 3]
runs-on: ubuntu-latest
name: cli-tests (shard ${{ matrix.shard }}/3)
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
steps:
- uses: actions/checkout@v4
@@ -262,10 +382,10 @@ jobs:
cache: "npm"
- name: Install dependencies
run: npm ci
run: node scripts/npm-retry.mjs ci
- name: Install agent CLIs for provider tests
run: npm install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
- name: Run CLI tests
run: npm run test --workspace=@getpaseo/cli

View File

@@ -24,10 +24,9 @@ jobs:
scope: "@boudra"
- name: Install dependencies
run: npm ci
run: node scripts/npm-retry.mjs ci
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Build app dependencies
run: npm run build:app-deps

View File

@@ -1,11 +1,8 @@
name: Deploy Relay
on:
push:
branches: [main]
paths:
- "packages/relay/**"
- ".github/workflows/deploy-relay.yml"
# Manual-only while relay.paseo.sh bridges traffic to the Fly deployment.
# A release or main push must not redeploy the temporary Cloudflare bridge.
workflow_dispatch:
jobs:
@@ -21,7 +18,7 @@ jobs:
cache: "npm"
- name: Install dependencies
run: npm ci --workspace=@getpaseo/relay --include-workspace-root
run: node scripts/npm-retry.mjs ci --workspace=@getpaseo/relay --include-workspace-root
- name: Typecheck
run: npm run typecheck --workspace=@getpaseo/relay

View File

@@ -29,7 +29,7 @@ jobs:
cache: "npm"
- name: Install dependencies
run: npm ci --workspace=@getpaseo/website --include-workspace-root
run: node scripts/npm-retry.mjs ci --workspace=@getpaseo/website --include-workspace-root
- name: Typecheck
run: npm run typecheck --workspace=@getpaseo/website

View File

@@ -127,7 +127,7 @@ jobs:
scope: "@boudra"
- name: Install JS dependencies
run: npm ci
run: node scripts/npm-retry.mjs ci
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -158,6 +158,7 @@ jobs:
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
PASEO_DESKTOP_SMOKE: "1"
PASEO_DESKTOP_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-smoke
run: |
set -euo pipefail
build_args=(-- --publish never --mac --${{ matrix.electron_arch }})
@@ -165,6 +166,15 @@ jobs:
build_args+=("-c.publish.channel=$RELEASE_CHANNEL")
npm run build:desktop "${build_args[@]}"
- name: Upload packaged smoke diagnostics
if: failure()
uses: actions/upload-artifact@v4
with:
name: desktop-smoke-macos-${{ matrix.electron_arch }}
path: ${{ runner.temp }}/desktop-smoke
if-no-files-found: ignore
retention-days: 7
- name: Upload desktop artifacts to release
if: env.SHOULD_PUBLISH == 'true' && env.IS_SMOKE_TAG != 'true'
env:
@@ -227,7 +237,7 @@ jobs:
scope: "@boudra"
- name: Install JS dependencies
run: npm ci
run: node scripts/npm-retry.mjs ci
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -248,7 +258,7 @@ jobs:
NODE
- name: Install Linux smoke display
run: sudo apt-get update && sudo apt-get install -y xvfb
run: sudo apt-get update && sudo apt-get install -y xvfb xauth
- name: Build desktop release
shell: bash
@@ -256,6 +266,7 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
EP_GH_IGNORE_TIME: true
PASEO_DESKTOP_SMOKE: "1"
PASEO_DESKTOP_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-smoke
run: |
set -euo pipefail
build_args=(-- --publish never --linux --x64)
@@ -263,6 +274,15 @@ jobs:
build_args+=("-c.publish.channel=$RELEASE_CHANNEL")
npm run build:desktop "${build_args[@]}"
- name: Upload packaged smoke diagnostics
if: failure()
uses: actions/upload-artifact@v4
with:
name: desktop-smoke-linux-x64
path: ${{ runner.temp }}/desktop-smoke
if-no-files-found: ignore
retention-days: 7
- name: Upload desktop artifacts to release
if: env.SHOULD_PUBLISH == 'true' && env.IS_SMOKE_TAG != 'true'
env:
@@ -325,7 +345,7 @@ jobs:
scope: "@boudra"
- name: Install JS dependencies
run: npm ci
run: node scripts/npm-retry.mjs ci
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -351,6 +371,7 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
EP_GH_IGNORE_TIME: true
PASEO_DESKTOP_SMOKE: "1"
PASEO_DESKTOP_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-smoke
run: |
set -euo pipefail
build_args=(-- --publish never --win --x64 --arm64)
@@ -358,6 +379,15 @@ jobs:
build_args+=("-c.publish.channel=$RELEASE_CHANNEL")
npm run build:desktop "${build_args[@]}"
- name: Upload packaged smoke diagnostics
if: failure()
uses: actions/upload-artifact@v4
with:
name: desktop-smoke-windows-x64
path: ${{ runner.temp }}/desktop-smoke
if-no-files-found: ignore
retention-days: 7
- name: Upload desktop artifacts to release
if: env.SHOULD_PUBLISH == 'true' && env.IS_SMOKE_TAG != 'true'
env:
@@ -425,7 +455,7 @@ jobs:
scope: "@boudra"
- name: Install JS dependencies
run: npm ci
run: node scripts/npm-retry.mjs ci
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -47,7 +47,7 @@ jobs:
scope: "@boudra"
- name: Install JS dependencies
run: npm ci
run: node scripts/npm-retry.mjs ci
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

189
.github/workflows/docker.yml vendored Normal file
View File

@@ -0,0 +1,189 @@
name: Docker
on:
pull_request:
branches: [main]
paths:
- "docker/**"
- ".dockerignore"
- ".github/workflows/docker.yml"
- "package.json"
- "package-lock.json"
- "patches/**"
- "scripts/**"
- "tsconfig.json"
- "tsconfig.base.json"
- "packages/app/**"
- "packages/cli/**"
- "packages/client/**"
- "packages/expo-two-way-audio/**"
- "packages/highlight/**"
- "packages/protocol/**"
- "packages/relay/**"
- "packages/server/**"
push:
branches: [main]
tags:
- "v*"
workflow_dispatch:
inputs:
paseo_version:
description: "Expected source version to build. Required when publish is true."
required: false
default: ""
publish:
description: "Publish the image to GHCR. Manual publishes require paseo_version."
required: false
default: "false"
type: choice
options:
- "false"
- "true"
publish_latest:
description: "Also publish ghcr.io/getpaseo/paseo:latest. Ignored for prereleases."
required: false
default: "false"
type: choice
options:
- "false"
- "true"
concurrency:
group: docker-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
REGISTRY: ghcr.io
PLATFORMS: linux/amd64,linux/arm64
jobs:
setup:
runs-on: ubuntu-latest
outputs:
image: ${{ steps.values.outputs.image }}
install_version: ${{ steps.values.outputs.install_version }}
publish: ${{ steps.values.outputs.publish }}
check_tag: ${{ steps.values.outputs.check_tag }}
publish_tags: ${{ steps.values.outputs.publish_tags }}
steps:
- uses: actions/checkout@v6
- id: values
env:
INPUT_PASEO_VERSION: ${{ inputs.paseo_version }}
INPUT_PUBLISH: ${{ inputs.publish }}
INPUT_PUBLISH_LATEST: ${{ inputs.publish_latest }}
REPO_OWNER: ${{ github.repository_owner }}
REF_NAME: ${{ github.ref_name }}
run: |
set -euo pipefail
owner="$(printf '%s' "${REPO_OWNER}" | tr '[:upper:]' '[:lower:]')"
image="ghcr.io/${owner}/paseo"
package_version="$(node -p "require('./package.json').version")"
install_version="${INPUT_PASEO_VERSION:-${package_version}}"
publish=false
publish_latest=false
prerelease=false
if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
install_version="${REF_NAME#v}"
publish=true
if [[ "${REF_NAME}" == *-* ]]; then
prerelease=true
else
publish_latest=true
fi
elif [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then
if [[ "${INPUT_PUBLISH:-false}" == "true" ]]; then
if [[ -z "${INPUT_PASEO_VERSION}" || "${INPUT_PASEO_VERSION}" == "latest" ]]; then
echo "::error::paseo_version is required for manual Docker publishes."
exit 1
fi
publish=true
fi
if [[ "${install_version}" == *-* ]]; then
prerelease=true
fi
if [[ "${INPUT_PUBLISH_LATEST:-false}" == "true" && "${prerelease}" != "true" ]]; then
publish_latest=true
fi
fi
check_tag="${image}:check-${GITHUB_SHA::12}"
publish_tags="${image}:${install_version}"
if [[ "${publish_latest}" == "true" ]]; then
publish_tags="${publish_tags}"$'\n'"${image}:latest"
fi
{
echo "image=${image}"
echo "install_version=${install_version}"
echo "publish=${publish}"
echo "check_tag=${check_tag}"
echo "publish_tags<<EOF"
echo "${publish_tags}"
echo "EOF"
} >> "$GITHUB_OUTPUT"
echo "Resolved image=${image} install_version=${install_version} publish=${publish}"
build:
needs: setup
if: needs.setup.outputs.publish != 'true'
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v6
- uses: docker/setup-qemu-action@v4
- uses: docker/setup-buildx-action@v4
- uses: docker/build-push-action@v7
with:
context: .
file: docker/base/Dockerfile
platforms: ${{ env.PLATFORMS }}
build-args: |
PASEO_VERSION=${{ needs.setup.outputs.install_version }}
tags: ${{ needs.setup.outputs.check_tag }}
push: false
provenance: false
cache-from: type=gha,scope=paseo
cache-to: type=gha,scope=paseo,mode=max
publish:
needs: setup
if: needs.setup.outputs.publish == 'true'
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v6
- uses: docker/setup-qemu-action@v4
- uses: docker/setup-buildx-action@v4
- name: Log in to GHCR
uses: docker/login-action@v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v7
with:
context: .
file: docker/base/Dockerfile
platforms: ${{ env.PLATFORMS }}
build-args: |
PASEO_VERSION=${{ needs.setup.outputs.install_version }}
tags: ${{ needs.setup.outputs.publish_tags }}
push: true
provenance: false
cache-from: type=gha,scope=paseo
cache-to: type=gha,scope=paseo,mode=max

View File

@@ -9,12 +9,15 @@ on:
- "flake.lock"
- "package.json"
- "package-lock.json"
- "packages/app/**"
- "packages/expo-two-way-audio/**"
- "packages/highlight/**"
- "packages/protocol/**"
- "packages/client/**"
- "packages/server/**"
- "packages/relay/**"
- "packages/cli/**"
- "scripts/build-daemon-web-ui.mjs"
- "scripts/update-nix.sh"
- "scripts/fix-lockfile.mjs"
- ".github/workflows/nix.yml"
@@ -64,7 +67,7 @@ jobs:
}
trap cleanup EXIT
./result/bin/paseo-server --no-relay >"$WRAPPER_LOG" 2>&1 &
PASEO_WEB_UI_ENABLED=true ./result/bin/paseo-server --no-relay >"$WRAPPER_LOG" 2>&1 &
DAEMON_PID=$!
deadline=$((SECONDS + 30))
@@ -72,6 +75,8 @@ jobs:
if STATUS_JSON="$(./result/bin/paseo daemon status --json)" \
&& jq -e '.connectedDaemon == "reachable"' <<<"$STATUS_JSON" >/dev/null; then
echo "$STATUS_JSON"
curl --fail --silent --show-error http://127.0.0.1:6767/ >"$PASEO_HOME/web-ui.html"
[[ -s "$PASEO_HOME/web-ui.html" ]]
exit 0
fi

2
.gitignore vendored
View File

@@ -64,6 +64,7 @@ CLAUDE.local.md
.debug.conversations/
.debug/
.dev/
.paseo/
.wrangler/
**/.wrangler/
@@ -83,6 +84,7 @@ valknut-report.json/
.plans/
packages/server/src/server/fixtures/dictation/dictation-debug-largest.wav
packages/server/src/server/fixtures/dictation/dictation-debug-largest.transcript.txt
packages/protocol/src/generated/validation/*.aot.ts
/artifacts
packages/desktop/.cache/

View File

@@ -1,9 +1,10 @@
[env]
ANDROID_HOME = "{{env.HOME}}/.local/share/mise/installs/android-sdk/1.0"
ANDROID_HOME = "{{env.HOME}}/.local/share/mise/installs/android-sdk/21.0"
_.path = [
"{{env.HOME}}/.local/share/mise/installs/android-sdk/1.0/platform-tools",
"{{env.HOME}}/.local/share/mise/installs/android-sdk/1.0/emulator",
"{{env.HOME}}/.local/share/mise/installs/android-sdk/21.0/cmdline-tools/21.0/bin",
"{{env.HOME}}/.local/share/mise/installs/android-sdk/21.0/platform-tools",
"{{env.HOME}}/.local/share/mise/installs/android-sdk/21.0/emulator",
]
[tools]
java = "17"
java = "21"

View File

@@ -3,6 +3,7 @@
"options": {
"typeAware": false
},
"ignorePatterns": [".dev/**"],
"plugins": ["react", "react-perf", "unicorn", "typescript", "oxc", "import", "promise"],
"categories": {
"correctness": "error",
@@ -83,6 +84,201 @@
"max-nested-callbacks": ["error", { "max": 3 }]
},
"overrides": [
{
"files": ["packages/app/src/**/*.{ts,tsx}"],
"rules": {
// React Native style arrays must read Unistyles proxies during render. Hoisting them to
// satisfy this allocation rule captures the temporary startup theme instead.
"react-perf/jsx-no-new-array-as-prop": "off",
"no-restricted-imports": [
"error",
{
"paths": [
{
"name": "@tanstack/react-query",
"importNames": ["useQuery", "useInfiniteQuery", "useQueries"],
"message": "App reads must go through useReplicaQuery/useFetchQuery from @/data/query. Grandfathered files may only leave the override burn-down list."
},
{
"name": "react-native-unistyles",
"importNames": ["useUnistyles"],
"message": "useUnistyles is banned by docs/unistyles.md. Grandfathered files may only leave the override burn-down list."
}
]
}
]
}
},
{
"files": [
"packages/app/src/data/**/*.{ts,tsx}",
"packages/app/src/**/*.test.{ts,tsx}",
"packages/app/src/**/*.spec.{ts,tsx}"
],
"rules": {
"no-restricted-imports": [
"error",
{
"paths": [
{
"name": "react-native-unistyles",
"importNames": ["useUnistyles"],
"message": "useUnistyles is banned by docs/unistyles.md. Grandfathered files may only leave the override burn-down list."
}
]
}
]
}
},
// Raw query burn-down: 35 files total; the 29 files below still enforce the useUnistyles ban.
{
"files": [
"packages/app/src/assistant-file-links/use-file-link.ts",
"packages/app/src/components/message.tsx",
"packages/app/src/components/worktree-setup-callout-source.tsx",
"packages/app/src/desktop/hooks/use-daemon-status.ts",
"packages/app/src/desktop/hooks/use-install-status.ts",
"packages/app/src/desktop/settings/desktop-settings.ts",
"packages/app/src/git/pull-request-panel/use-data.ts",
"packages/app/src/git/use-github-search-query.ts",
"packages/app/src/git/use-pr-status-query.ts",
"packages/app/src/git/use-status-query.ts",
"packages/app/src/hooks/use-archive-agent.ts",
"packages/app/src/hooks/use-agent-autocomplete.ts",
"packages/app/src/hooks/use-agent-commands-query.ts",
"packages/app/src/hooks/use-agent-history.ts",
"packages/app/src/hooks/use-branch-switcher.ts",
"packages/app/src/hooks/use-changes-preferences/index.ts",
"packages/app/src/hooks/use-draft-agent-features.ts",
"packages/app/src/hooks/use-form-preferences.ts",
"packages/app/src/hooks/use-is-local-daemon.ts",
"packages/app/src/hooks/use-keyboard-shortcut-overrides.ts",
"packages/app/src/hooks/use-preferred-editor.ts",
"packages/app/src/hooks/use-project-icon-query.ts",
"packages/app/src/hooks/use-settings/index.ts",
"packages/app/src/panels/terminal-panel.tsx",
"packages/app/src/projects/project-icons.ts",
"packages/app/src/provider-usage/use-provider-usage.ts",
"packages/app/src/screens/project-settings-screen.tsx",
"packages/app/src/screens/workspace/use-workspace-checkout-status.ts",
"packages/app/src/workspace/desktop-open-targets.ts"
],
"rules": {
"no-restricted-imports": [
"error",
{
"paths": [
{
"name": "react-native-unistyles",
"importNames": ["useUnistyles"],
"message": "useUnistyles is banned by docs/unistyles.md. Grandfathered files may only leave the override burn-down list."
}
]
}
]
}
},
// useUnistyles burn-down: 74 files total; the 68 files below still enforce the raw-query ban.
{
"files": [
"packages/app/src/app/_layout.tsx",
"packages/app/src/app/pair-scan.tsx",
"packages/app/src/components/adaptive-modal-sheet.tsx",
"packages/app/src/components/add-host-method-modal.tsx",
"packages/app/src/components/add-host-modal.tsx",
"packages/app/src/components/agent-list.tsx",
"packages/app/src/components/agent-status-dot.tsx",
"packages/app/src/components/attachment-lightbox.tsx",
"packages/app/src/components/browser-pane.electron.tsx",
"packages/app/src/components/browser-pane.tsx",
"packages/app/src/components/browser-pane.web.tsx",
"packages/app/src/components/command-center.tsx",
"packages/app/src/components/context-window-meter.tsx",
"packages/app/src/components/dictation-controls.tsx",
"packages/app/src/components/download-toast.tsx",
"packages/app/src/components/draggable-list.native.tsx",
"packages/app/src/components/explorer-sidebar.tsx",
"packages/app/src/components/headers/back-header.tsx",
"packages/app/src/components/headers/menu-header.tsx",
"packages/app/src/components/headers/screen-header.tsx",
"packages/app/src/components/host-status-dot.tsx",
"packages/app/src/components/hosts/host-picker.tsx",
"packages/app/src/components/icons/paseo-logo.tsx",
"packages/app/src/components/left-sidebar.tsx",
"packages/app/src/components/pair-link-modal.tsx",
"packages/app/src/components/plan-card.tsx",
"packages/app/src/components/provider-diagnostic-sheet.tsx",
"packages/app/src/components/question-form-card.tsx",
"packages/app/src/components/quitting-overlay.tsx",
"packages/app/src/components/realtime-voice-overlay.tsx",
"packages/app/src/components/resize-handle.tsx",
"packages/app/src/components/rewind/rewind-menu.tsx",
"packages/app/src/components/settings-textarea.tsx",
"packages/app/src/components/sidebar-callout.tsx",
"packages/app/src/components/split-container.tsx",
"packages/app/src/components/split-drop-zone.tsx",
"packages/app/src/components/terminal-pane.tsx",
"packages/app/src/components/toast-host.tsx",
"packages/app/src/components/tool-call-sheet.tsx",
"packages/app/src/components/ui/alert.tsx",
"packages/app/src/components/ui/autocomplete.tsx",
"packages/app/src/components/ui/combobox.tsx",
"packages/app/src/components/ui/context-menu.tsx",
"packages/app/src/components/ui/dropdown-menu.tsx",
"packages/app/src/components/ui/external-link.tsx",
"packages/app/src/components/volume-meter.tsx",
"packages/app/src/components/web-desktop-scrollbar.tsx",
"packages/app/src/components/welcome-screen.tsx",
"packages/app/src/composer/agent-controls/index.tsx",
"packages/app/src/composer/agent-controls/mode-control.tsx",
"packages/app/src/constants/layout.ts",
"packages/app/src/desktop/components/desktop-permission-row.tsx",
"packages/app/src/desktop/components/desktop-permissions-section.tsx",
"packages/app/src/desktop/components/desktop-updates-section.tsx",
"packages/app/src/desktop/components/integrations-section.tsx",
"packages/app/src/desktop/updates/update-callout-source.tsx",
"packages/app/src/git/actions-split-button.tsx",
"packages/app/src/hooks/use-web-scrollbar-style.web.ts",
"packages/app/src/hosts/host-chooser.tsx",
"packages/app/src/screens/open-project-screen.tsx",
"packages/app/src/screens/projects-screen.tsx",
"packages/app/src/screens/sessions-screen.tsx",
"packages/app/src/screens/settings-screen.tsx",
"packages/app/src/screens/settings/host-page.tsx",
"packages/app/src/screens/settings/providers-section.tsx",
"packages/app/src/screens/settings/settings-group.tsx",
"packages/app/src/screens/startup-splash-screen.tsx",
"packages/app/src/screens/workspace/workspace-route-state-views.tsx"
],
"rules": {
"no-restricted-imports": [
"error",
{
"paths": [
{
"name": "@tanstack/react-query",
"importNames": ["useQuery", "useInfiniteQuery", "useQueries"],
"message": "App reads must go through useReplicaQuery/useFetchQuery from @/data/query. Grandfathered files may only leave the override burn-down list."
}
]
}
]
}
},
// Both burn-downs: 6 overlapping files. They may only shrink out of this exemption.
{
"files": [
"packages/app/src/components/file-explorer-pane.tsx",
"packages/app/src/components/file-pane.tsx",
"packages/app/src/components/import-session-sheet.tsx",
"packages/app/src/components/project-picker-modal.tsx",
"packages/app/src/desktop/components/pair-device-section.tsx",
"packages/app/src/screens/new-workspace-screen.tsx"
],
"rules": {
"no-restricted-imports": "off"
}
},
{
"files": ["**/e2e/fixtures.ts"],
"rules": {

View File

@@ -1,4 +1,4 @@
rust 1.85.1
nodejs 22.20.0
java 21
android-sdk latest
android-sdk 21.0

View File

@@ -1,5 +1,571 @@
# Changelog
## 0.2.0-beta.1 - 2026-07-17
### Added
- Work with pull requests and merge requests from GitLab, Gitea, Forgejo, and Codeberg ([#1913](https://github.com/getpaseo/paseo/pull/1913) by [@nllptrx](https://github.com/nllptrx))
- Use Oh My Pi as a native agent provider ([#2067](https://github.com/getpaseo/paseo/pull/2067) by [@ebg1223](https://github.com/ebg1223))
- Browse branch commit history and open individual commit diffs from Changes ([#1534](https://github.com/getpaseo/paseo/pull/1534), [#2146](https://github.com/getpaseo/paseo/pull/2146) by [@adradr](https://github.com/adradr))
- Open workspace files in more installed editors and file managers ([#2119](https://github.com/getpaseo/paseo/pull/2119))
- Remove individual custom providers from Settings ([#1951](https://github.com/getpaseo/paseo/pull/1951))
### Improved
- ACP provider catalog updated to the latest registry versions
- Workspace focus mode stays confined to the active workspace with a visible exit control ([#2151](https://github.com/getpaseo/paseo/pull/2151))
- Desktop installs the newest available update instead of a cached older release ([#2149](https://github.com/getpaseo/paseo/pull/2149))
- Remote daemon update failures now show specific recovery steps ([#2120](https://github.com/getpaseo/paseo/pull/2120))
- Agent history errors now appear immediately instead of after a timeout ([#2124](https://github.com/getpaseo/paseo/pull/2124))
### Fixed
- Terminal panes no longer remain at 80x24 after focus or visibility changes ([#2059](https://github.com/getpaseo/paseo/pull/2059), [#2154](https://github.com/getpaseo/paseo/pull/2154) by [@cleiter](https://github.com/cleiter))
- Sign-in popups in the desktop browser now complete successfully ([#2137](https://github.com/getpaseo/paseo/pull/2137))
- Browser typing and shortcuts no longer submit the active Paseo prompt ([#1982](https://github.com/getpaseo/paseo/pull/1982))
- Agent browser tabs remain controllable after switching workspaces ([#2156](https://github.com/getpaseo/paseo/pull/2156))
- Archived workspaces now show the correct Unarchive or Restore action ([#2002](https://github.com/getpaseo/paseo/pull/2002))
- Archived sessions can be reimported into the current workspace ([#2123](https://github.com/getpaseo/paseo/pull/2123))
- Browser shortcuts no longer appear where browser tabs are unavailable ([#2116](https://github.com/getpaseo/paseo/pull/2116) by [@jasonhnd](https://github.com/jasonhnd))
## 0.1.110 - 2026-07-16
### Fixed
- Kimi and other ACP agents now stay marked as running while a response is actively streaming ([#2148](https://github.com/getpaseo/paseo/pull/2148))
## 0.1.109 - 2026-07-16
> **Important update notice**
>
> If you installed Paseo Desktop 0.1.108, you need to [download and reinstall Paseo manually](https://paseo.sh/download) to get this fix. The bug in 0.1.108 prevents its automatic updater from installing 0.1.109. Users on 0.1.107 or earlier can update normally.
### Fixed
- Paseo Desktop no longer gets stuck connecting or loses native window controls after updating ([#2111](https://github.com/getpaseo/paseo/pull/2111) by [@cleiter](https://github.com/cleiter))
## 0.1.108 - 2026-07-16
### Added
- Create a new project folder or clone a GitHub repository from Add Project ([#1331](https://github.com/getpaseo/paseo/pull/1331), [#2045](https://github.com/getpaseo/paseo/pull/2045), [#2097](https://github.com/getpaseo/paseo/pull/2097) by [@mcowger](https://github.com/mcowger))
- Search for and open workspaces from the search menu ([#2096](https://github.com/getpaseo/paseo/pull/2096))
- Pin workspaces to the top of the sidebar ([#1981](https://github.com/getpaseo/paseo/pull/1981) by [@half144](https://github.com/half144))
- Summarize tool calls in a single collapsed item with a new appearance setting ([#2031](https://github.com/getpaseo/paseo/pull/2031), [#2069](https://github.com/getpaseo/paseo/pull/2069), [#2090](https://github.com/getpaseo/paseo/pull/2090) by [@mcowger](https://github.com/mcowger))
- Save browser cookies and site data across tabs and restarts ([#2089](https://github.com/getpaseo/paseo/pull/2089))
- Claude and Codex subagents now show their actual names, with a new option to archive finished Claude Code, Codex, and OpenCode subagents ([#2073](https://github.com/getpaseo/paseo/pull/2073))
- Fork chats from failed turns ([#2063](https://github.com/getpaseo/paseo/pull/2063))
### Improved
- Permission modes have clearer icons ([#1980](https://github.com/getpaseo/paseo/pull/1980) by [@cleiter](https://github.com/cleiter))
- Desktop stays usable in narrower windows ([#1983](https://github.com/getpaseo/paseo/pull/1983))
- Sidebar controls stay in place when desktop panels open and close ([#2078](https://github.com/getpaseo/paseo/pull/2078))
- Typing in long drafts is smoother ([#2086](https://github.com/getpaseo/paseo/pull/2086))
- Codex terminal commands always appear in chat, even when they have no output ([#2037](https://github.com/getpaseo/paseo/pull/2037))
### Fixed
- New Workspace keeps your prompt and attachments when you switch projects or hosts ([#2030](https://github.com/getpaseo/paseo/pull/2030), [#2036](https://github.com/getpaseo/paseo/pull/2036))
- OpenCode sessions close without crashing Paseo ([#2027](https://github.com/getpaseo/paseo/pull/2027) by [@mcowger](https://github.com/mcowger))
- Pi slash commands no longer leave chats stuck as running ([#2066](https://github.com/getpaseo/paseo/pull/2066) by [@ebg1223](https://github.com/ebg1223))
- Background-agent updates now appear after the main reply ([#2058](https://github.com/getpaseo/paseo/pull/2058) by [@1254087415](https://github.com/1254087415))
- Codex subagents no longer disappear from the Subagents track ([#2068](https://github.com/getpaseo/paseo/pull/2068))
- Forked chats open ready to edit in their new tab ([#2038](https://github.com/getpaseo/paseo/pull/2038))
- Paseo Desktop opens normally after an interrupted shutdown ([#1962](https://github.com/getpaseo/paseo/pull/1962))
- Keyboard shortcuts now work with `-`, `=`, `;`, and `'` ([#2047](https://github.com/getpaseo/paseo/pull/2047) by [@OnCloud125252](https://github.com/OnCloud125252))
- Codebuddy Code models now appear in the model picker ([#1979](https://github.com/getpaseo/paseo/pull/1979) by [@park0er](https://github.com/park0er))
- Workspace search now includes OpenCode commands and workflows ([#2049](https://github.com/getpaseo/paseo/pull/2049))
- Nix installations now include the Paseo web app ([#1978](https://github.com/getpaseo/paseo/pull/1978) by [@liamdiprose](https://github.com/liamdiprose))
## 0.1.107 - 2026-07-13
### Added
- Inspect provider-created subagents and their live conversations from the Subagents track ([#2013](https://github.com/getpaseo/paseo/pull/2013) by [@omercnet](https://github.com/omercnet))
- Fork chats with every supported agent provider ([#2022](https://github.com/getpaseo/paseo/pull/2022))
### Improved
- Add projects directly from New Workspace when none are configured ([#2026](https://github.com/getpaseo/paseo/pull/2026))
- New terminals open at the correct size immediately ([#2023](https://github.com/getpaseo/paseo/pull/2023) by [@cleiter](https://github.com/cleiter))
- Sidebar footer actions now explain themselves with tooltips ([#2025](https://github.com/getpaseo/paseo/pull/2025))
- Codex shell tool calls show only the command being run ([#2029](https://github.com/getpaseo/paseo/pull/2029))
- Custom ACP providers keep file and terminal work in the agent environment by default ([#2024](https://github.com/getpaseo/paseo/pull/2024))
- ACP provider catalog updated to the latest registry versions
### Fixed
- Large tables no longer make iOS chats unresponsive
- Chat controls remain clickable near the scroll-to-bottom button ([#2007](https://github.com/getpaseo/paseo/pull/2007))
- Oversized tool output no longer slows or floods chat timelines ([#2020](https://github.com/getpaseo/paseo/pull/2020))
- Cross-provider subagents can use providers without mode settings ([#2000](https://github.com/getpaseo/paseo/pull/2000) by [@githubbzxs](https://github.com/githubbzxs))
- Pi's internal metadata tasks no longer clutter normal session history ([#1999](https://github.com/getpaseo/paseo/pull/1999) by [@githubbzxs](https://github.com/githubbzxs))
- Pi chats remain usable after canceling extension commands ([#2019](https://github.com/getpaseo/paseo/pull/2019))
## 0.1.106 - 2026-07-12
### Added
- Approve Codex MCP permission requests in Paseo ([#2001](https://github.com/getpaseo/paseo/pull/2001))
### Improved
- ACP provider catalog updated to the latest registry versions
### Fixed
- Reduced mobile chat freezes and blank screens when switching workspaces while agents are streaming ([#1989](https://github.com/getpaseo/paseo/pull/1989))
- OpenCode sessions start reliably instead of occasionally losing the first turn ([#2015](https://github.com/getpaseo/paseo/pull/2015) by [@mcowger](https://github.com/mcowger))
- Switching between workspaces no longer flashes a white screen
- Pi keeps your existing MCP tools and settings when Paseo adds its own ([#1990](https://github.com/getpaseo/paseo/pull/1990) by [@mcowger](https://github.com/mcowger))
## 0.1.105 - 2026-07-10
### Added
- Browse changed files as a collapsible folder tree or flat list ([#1918](https://github.com/getpaseo/paseo/pull/1918), [#1945](https://github.com/getpaseo/paseo/pull/1945) by [@cleiter](https://github.com/cleiter))
- Always expand agent reasoning with a new appearance setting ([#1943](https://github.com/getpaseo/paseo/pull/1943) by [@mcowger](https://github.com/mcowger))
### Improved
- Project picker finds folders with fuzzy search and native desktop browsing ([#1968](https://github.com/getpaseo/paseo/pull/1968))
- Large workspace sidebars stay responsive ([#1966](https://github.com/getpaseo/paseo/pull/1966))
- Generated workspace names and Git text can use MiniMax M3 ([#1955](https://github.com/getpaseo/paseo/pull/1955) by [@octo-patch](https://github.com/octo-patch))
- Cursor now exposes thinking and fast mode ([#1952](https://github.com/getpaseo/paseo/pull/1952))
### Fixed
- Codex stays active and streams correctly while subagents run ([#1967](https://github.com/getpaseo/paseo/pull/1967))
- Android audio interruptions no longer crash voice mode or leave dictation stuck ([#1941](https://github.com/getpaseo/paseo/pull/1941))
- Mobile sidebars stay in sync and retain swipe-to-open gestures ([#1953](https://github.com/getpaseo/paseo/pull/1953), [#1976](https://github.com/getpaseo/paseo/pull/1976))
- Pi text-only models accept image prompts without breaking the session ([#1960](https://github.com/getpaseo/paseo/pull/1960))
- App render failures show a retryable recovery screen instead of a blank screen ([#1924](https://github.com/getpaseo/paseo/pull/1924))
- Pi context usage remains visible with older Oh My Pi versions ([#1886](https://github.com/getpaseo/paseo/pull/1886) by [@theslava](https://github.com/theslava))
- Provider usage popovers no longer error when opened and closed quickly ([#1885](https://github.com/getpaseo/paseo/pull/1885) by [@theslava](https://github.com/theslava))
- Mobile workspace menus hide desktop-only shortcut badges ([#1964](https://github.com/getpaseo/paseo/pull/1964))
## 0.1.104 - 2026-07-08
### Added
- Agents can drive the in-app browser with page snapshots, trusted input, dialogs, and tab controls ([#1881](https://github.com/getpaseo/paseo/pull/1881))
- Inspect, annotate, and send page elements from a browser tab to the agent ([#1708](https://github.com/getpaseo/paseo/pull/1708) by [@huiliaoning](https://github.com/huiliaoning))
- Schedules screen to create and manage recurring agents ([#1246](https://github.com/getpaseo/paseo/pull/1246))
- Open a project from anywhere with Cmd+O ([#1849](https://github.com/getpaseo/paseo/pull/1849))
- Agents can rename workspaces after they understand the task ([#1876](https://github.com/getpaseo/paseo/pull/1876))
- Claude Ultra Code is available for supported Claude models ([#1872](https://github.com/getpaseo/paseo/pull/1872))
- ByteDance TRAE CLI available as an agent provider ([#1831](https://github.com/getpaseo/paseo/pull/1831), [#1896](https://github.com/getpaseo/paseo/pull/1896) by [@park0er](https://github.com/park0er))
### Improved
- Manage the built-in daemon from one place in desktop settings ([#1938](https://github.com/getpaseo/paseo/pull/1938))
- Scheduled and loop runs each get their own workspace in the sidebar ([#1909](https://github.com/getpaseo/paseo/pull/1909), [#1934](https://github.com/getpaseo/paseo/pull/1934))
- Large provider and model refreshes load faster in the app ([#1895](https://github.com/getpaseo/paseo/pull/1895))
- Workspaces created by agents now get readable generated names ([#1887](https://github.com/getpaseo/paseo/pull/1887))
- Browser tabs opened by agents stay in the background until you switch to them ([#1875](https://github.com/getpaseo/paseo/pull/1875))
- Clearer cards when an agent asks a question ([#1643](https://github.com/getpaseo/paseo/pull/1643) by [@cleiter](https://github.com/cleiter))
- Diagnostic reports include desktop app logs ([#1914](https://github.com/getpaseo/paseo/pull/1914))
- Paseo's built-in tools take less context ([#1939](https://github.com/getpaseo/paseo/pull/1939))
### Fixed
- Renamed hosts keep their name after reconnecting ([#1940](https://github.com/getpaseo/paseo/pull/1940))
- Desktop finds your installed CLIs even when your shell is slow to start ([#1916](https://github.com/getpaseo/paseo/pull/1916))
- Restarting the daemon from desktop settings works reliably ([#1915](https://github.com/getpaseo/paseo/pull/1915))
- Restarting the daemon from the bundled CLI keeps it managed by the desktop app ([#1919](https://github.com/getpaseo/paseo/pull/1919))
- Web UI loads when the daemon is started from the bundled CLI ([#1899](https://github.com/getpaseo/paseo/pull/1899) by [@yzim](https://github.com/yzim))
- Worktree setup scripts keep your PATH ([#1908](https://github.com/getpaseo/paseo/pull/1908))
- Docker images keep running during provider cleanup and diagnostics ([#1877](https://github.com/getpaseo/paseo/pull/1877))
- New Workspace drafts survive archiving a workspace ([#1838](https://github.com/getpaseo/paseo/pull/1838))
- Composer autocomplete stays open after switching screens ([#1851](https://github.com/getpaseo/paseo/pull/1851))
- Claude usage appears when a quota window has no scheduled reset ([#1855](https://github.com/getpaseo/paseo/pull/1855))
- New workspace action shows for non-git projects in the sidebar ([#1857](https://github.com/getpaseo/paseo/pull/1857) by [@cleiter](https://github.com/cleiter))
## 0.1.103 - 2026-07-01
### Added
- Claude Sonnet 5 is available in the Claude model picker ([#1850](https://github.com/getpaseo/paseo/pull/1850))
## 0.1.102 - 2026-06-30
### Added
- Fork chats into a new tab or new worktree ([#1788](https://github.com/getpaseo/paseo/pull/1788))
- See workspaces from all connected hosts ([#1538](https://github.com/getpaseo/paseo/pull/1538), [#1775](https://github.com/getpaseo/paseo/pull/1775), [#1825](https://github.com/getpaseo/paseo/pull/1825))
- Daemon can now serve the web UI ([#1635](https://github.com/getpaseo/paseo/pull/1635), [#1739](https://github.com/getpaseo/paseo/pull/1739))
- Run Paseo from an official Docker image ([#1740](https://github.com/getpaseo/paseo/pull/1740) by [@Herbrant](https://github.com/Herbrant))
- Update a daemon remotely from the app ([#1513](https://github.com/getpaseo/paseo/pull/1513) by [@thedavidweng](https://github.com/thedavidweng))
- Configure separate OpenAI endpoints for speech-to-text and text-to-speech ([#1823](https://github.com/getpaseo/paseo/pull/1823))
- Drop files into any composer ([#1750](https://github.com/getpaseo/paseo/pull/1750), [#1801](https://github.com/getpaseo/paseo/pull/1801))
- Show MiniMax usage in quota views ([#1662](https://github.com/getpaseo/paseo/pull/1662) by [@ilteoood](https://github.com/ilteoood))
- Highlight C# code blocks ([#1651](https://github.com/getpaseo/paseo/pull/1651) by [@dev693](https://github.com/dev693))
### Improved
- New Workspace opens from anywhere ([#1746](https://github.com/getpaseo/paseo/pull/1746), [#1806](https://github.com/getpaseo/paseo/pull/1806))
- Project search shows loading progress ([#1762](https://github.com/getpaseo/paseo/pull/1762))
- Desktop update checks show clearer status ([#1808](https://github.com/getpaseo/paseo/pull/1808), [#1815](https://github.com/getpaseo/paseo/pull/1815))
- Slow remote hosts time out less aggressively ([#1789](https://github.com/getpaseo/paseo/pull/1789))
- Pi waits longer for extension results ([#1732](https://github.com/getpaseo/paseo/pull/1732) by [@theslava](https://github.com/theslava))
- Open file tabs refresh when you revisit them ([#1699](https://github.com/getpaseo/paseo/pull/1699) by [@cleiter](https://github.com/cleiter))
- Web terminals scroll more smoothly ([#1622](https://github.com/getpaseo/paseo/pull/1622) by [@TommyLike](https://github.com/TommyLike))
### Fixed
- Freshly added projects can be edited without restarting ([#1761](https://github.com/getpaseo/paseo/pull/1761) by [@huiliaoning](https://github.com/huiliaoning))
- Large repos open more reliably ([#1620](https://github.com/getpaseo/paseo/pull/1620) by [@jms830](https://github.com/jms830))
- Mobile restores the saved workspace on launch ([#1777](https://github.com/getpaseo/paseo/pull/1777))
- Agent prompts no longer rename workspaces ([#1779](https://github.com/getpaseo/paseo/pull/1779))
- Chat stays put when delayed history arrives ([#1776](https://github.com/getpaseo/paseo/pull/1776))
- Streamed chat images stay in order ([#1805](https://github.com/getpaseo/paseo/pull/1805))
- Chat actions stay below tool output ([#1827](https://github.com/getpaseo/paseo/pull/1827))
- Claude subagent narration stays out of chat ([#1807](https://github.com/getpaseo/paseo/pull/1807))
- Kiro slash commands and skills appear in Paseo ([#1792](https://github.com/getpaseo/paseo/pull/1792) by [@park0er](https://github.com/park0er))
- Agent lists survive stale project records ([#1812](https://github.com/getpaseo/paseo/pull/1812))
- Windows image previews handle drive-letter paths ([#1811](https://github.com/getpaseo/paseo/pull/1811))
- OpenCode closes cleanly on Windows ([#1771](https://github.com/getpaseo/paseo/pull/1771) by [@agamotto](https://github.com/agamotto))
- Desktop file uploads keep their extensions ([#1741](https://github.com/getpaseo/paseo/pull/1741))
- Claude Code cleanup kills child processes ([#1540](https://github.com/getpaseo/paseo/pull/1540) by [@TommyLike](https://github.com/TommyLike))
- OpenCode no longer indexes your home directory ([#1704](https://github.com/getpaseo/paseo/pull/1704) by [@rex-chang](https://github.com/rex-chang))
- Packaged macOS CLI daemon no longer shows extra Dock icons ([#1759](https://github.com/getpaseo/paseo/pull/1759) by [@yzim](https://github.com/yzim))
- `paseo daemon status` works without loading agents ([#1810](https://github.com/getpaseo/paseo/pull/1810))
- PR worktrees show pushed state correctly ([#1804](https://github.com/getpaseo/paseo/pull/1804))
## 0.1.101 - 2026-06-26
### Added
- Copy a troubleshooting report from Settings when support needs host, daemon, provider, and log details ([#1728](https://github.com/getpaseo/paseo/pull/1728))
- Claude image tool results now render as images in chat ([#1717](https://github.com/getpaseo/paseo/pull/1717))
- Added Japanese ([#1694](https://github.com/getpaseo/paseo/pull/1694) by [@sysCat64](https://github.com/sysCat64))
- Added Brazilian Portuguese ([#1653](https://github.com/getpaseo/paseo/pull/1653) by [@Alcimerio](https://github.com/Alcimerio))
### Improved
- Provider diagnostics stay useful even when model discovery is slow ([#1724](https://github.com/getpaseo/paseo/pull/1724))
- Slow provider requests no longer make the app look disconnected ([#1723](https://github.com/getpaseo/paseo/pull/1723))
- Worktrees linked to differently named tracked branches find their PRs correctly ([#1718](https://github.com/getpaseo/paseo/pull/1718))
- Workspaces started from slash-command prompts get clearer names ([#1709](https://github.com/getpaseo/paseo/pull/1709))
- ACP provider catalog updated to the latest registry versions
### Fixed
- Pi no longer creates empty sessions while loading new-agent options ([#1727](https://github.com/getpaseo/paseo/pull/1727))
- Windows daemon status finds the daemon process more reliably ([#1725](https://github.com/getpaseo/paseo/pull/1725))
- OpenAI voice credentials no longer affect other OpenAI-backed tools
- Provider model lists no longer disappear during refresh
## 0.1.100 - 2026-06-24
### Added
- Cycle agent modes with Shift+Tab
- Select a custom Copilot agent when starting or mid-session ([#1700](https://github.com/getpaseo/paseo/pull/1700))
### Improved
- ACP provider catalog updated to the latest registry versions
### Fixed
- Claude no longer sends an extra API request after each message ([#1701](https://github.com/getpaseo/paseo/pull/1701))
- OpenCode no longer leaves stray background servers running after sessions end ([#1697](https://github.com/getpaseo/paseo/pull/1697))
- Slash commands and skills now load in OMP agents ([#1698](https://github.com/getpaseo/paseo/pull/1698))
## 0.1.99 - 2026-06-23
### Improved
- The PR panel now has a refresh button and clearer loading states ([#1664](https://github.com/getpaseo/paseo/pull/1664))
- Provider diagnostics and model lists now stay in sync ([#1660](https://github.com/getpaseo/paseo/pull/1660))
### Fixed
- ACP providers like Grok no longer show duplicate user messages
- Saved composer modes no longer reset while provider data is loading ([#1658](https://github.com/getpaseo/paseo/pull/1658))
- The right sidebar no longer gets stuck on mobile ([#1661](https://github.com/getpaseo/paseo/pull/1661))
## 0.1.98 - 2026-06-21
### Added
- See plan usage in-app for Claude, Codex, Copilot, Cursor, Z.AI, Grok, and Kimi ([#1278](https://github.com/getpaseo/paseo/pull/1278) by [@ABorakati](https://github.com/ABorakati))
- Added Ultracode for Claude ([#1625](https://github.com/getpaseo/paseo/pull/1625))
- Detach a subagent to run it on its own ([#1612](https://github.com/getpaseo/paseo/pull/1612))
- Add a project without creating a workspace
- Add a setting to show branch names instead of titles in the sidebar
### Improved
- Mid-turn thinking and mode changes now say they apply next turn
- PR merge options name their method: squash, merge, or rebase ([#1608](https://github.com/getpaseo/paseo/pull/1608) by [@mcowger](https://github.com/mcowger))
- A running agent's mode change is remembered for new agents
- Copy a provider's launch diagnostic in one tap ([#1611](https://github.com/getpaseo/paseo/pull/1611))
### Fixed
- OpenCode no longer scans your whole disk on macOS desktop ([#1626](https://github.com/getpaseo/paseo/pull/1626))
- Daemon no longer crashes when OpenAI speech has no API key ([#1368](https://github.com/getpaseo/paseo/pull/1368) by [@mcowger](https://github.com/mcowger))
- Reopening an archived Codex agent no longer hangs
- Claude's context meter no longer jumps to subagent usage
- Claude's context meter fills from the first message in a new session
- OpenCode's mode picker now respects your disabled modes ([#1366](https://github.com/getpaseo/paseo/pull/1366) by [@mcowger](https://github.com/mcowger))
- File links and @-mentions find files in dot-folders and deep paths ([#1609](https://github.com/getpaseo/paseo/pull/1609))
- Archiving a project's last workspace no longer makes it vanish ([#1631](https://github.com/getpaseo/paseo/pull/1631))
- Collapsed sidebar projects stay collapsed
## 0.1.97 - 2026-06-18
### Added
- **Simplify workspace model** — run multiple workspaces on the same code without a worktree, each with its own agents, terminals, and status ([#1539](https://github.com/getpaseo/paseo/pull/1539))
- **Reopen archived workspaces from History** — restore a past workspace even after its worktree was removed
- **Terminals show when their agent is working, idle, or waiting for input** ([#1507](https://github.com/getpaseo/paseo/pull/1507))
- **Attach files to agents on mobile** ([#1501](https://github.com/getpaseo/paseo/pull/1501))
- **Hide dotfiles in the file explorer** ([#1516](https://github.com/getpaseo/paseo/pull/1516) by [@yuruiz](https://github.com/yuruiz))
- **Pin terminal, browser, and new-tab buttons to the tab row and sidebar**
- **Create a new workspace with a keyboard shortcut**
### Improved
- Workspace titles come from your first prompt and are shorter ([#1563](https://github.com/getpaseo/paseo/pull/1563))
- Copy a workspace's branch or path from its hover card
- Terminals stay smooth under heavy output ([#1500](https://github.com/getpaseo/paseo/pull/1500))
- Worktrees are removed when their last workspace is archived ([#1562](https://github.com/getpaseo/paseo/pull/1562))
- Finish notifications include subagent results ([#1558](https://github.com/getpaseo/paseo/pull/1558))
- Cursor lists only models you can select ([#1556](https://github.com/getpaseo/paseo/pull/1556))
- ACP provider catalog updated to the latest registry versions
### Fixed
- Brief daemon slowdowns no longer drop your connection
- Linux AppImage updates no longer hang on quit or delete the app ([#1485](https://github.com/getpaseo/paseo/pull/1485) by [@xpufx](https://github.com/xpufx))
- Opening Providers settings no longer crashes on Android ([#1537](https://github.com/getpaseo/paseo/pull/1537))
- Coding-agent terminal shortcuts work on Windows ([#1509](https://github.com/getpaseo/paseo/pull/1509))
- ACP and Kimi sessions can be imported again ([#1510](https://github.com/getpaseo/paseo/pull/1510) by [@wbxl2000](https://github.com/wbxl2000))
- ACP agents shut down without leaving orphaned processes ([#1460](https://github.com/getpaseo/paseo/pull/1460) by [@yeshan333](https://github.com/yeshan333))
- Imported session previews show clean prompts ([#1502](https://github.com/getpaseo/paseo/pull/1502))
- Local pairing offers use the correct app URL ([#1187](https://github.com/getpaseo/paseo/pull/1187) by [@aibaiiqpl](https://github.com/aibaiiqpl))
- The app no longer freezes from repeated provider re-probes
- Removing a project from the sidebar now removes the project itself instead of leaving it behind
- Workspace shortcut numbers no longer appear for the wrong key ([#1580](https://github.com/getpaseo/paseo/pull/1580) by [@cleiter](https://github.com/cleiter))
- Chats no longer hang when a message contains unmatched backticks ([#1585](https://github.com/getpaseo/paseo/pull/1585) by [@thaning0](https://github.com/thaning0))
## 0.1.96 - 2026-06-13
_This release only fixes an Android issue — desktop users don't need to update._
### Fixed
- On Android, the sidebar no longer reappears and gets stuck after you open a chat
## 0.1.95 - 2026-06-13
### Added
- **Attach any file to agents on desktop** ([#1474](https://github.com/getpaseo/paseo/pull/1474))
### Improved
- The git push button shows before merge actions when your branch is ahead ([#1488](https://github.com/getpaseo/paseo/pull/1488))
- SVG attachments are uploaded to disk
- Switching workspaces feels smoother
### Fixed
- Fixed cases where outdated GitHub data could be shown ([#1491](https://github.com/getpaseo/paseo/pull/1491))
- Uploaded images in PR comments and review threads now load in the PR panel ([#1486](https://github.com/getpaseo/paseo/pull/1486))
- Opening a project whose folder is missing shows a clear error ([#1490](https://github.com/getpaseo/paseo/pull/1490))
- The new workspace title moves out of the way of the keyboard ([#1489](https://github.com/getpaseo/paseo/pull/1489))
- Sidebars no longer open on their own on Android
## 0.1.94 - 2026-06-12
### Added
- **Attach pull request comments, reviews, threads, and failed check logs to chat from the PR panel** ([#1400](https://github.com/getpaseo/paseo/pull/1400))
- **Use Paseo in Arabic, Chinese, English, French, Russian, and Spanish** ([#1282](https://github.com/getpaseo/paseo/pull/1282), [#1478](https://github.com/getpaseo/paseo/pull/1478) by [@chyendongnhanh338](https://github.com/chyendongnhanh338), [@dwyanewang](https://github.com/dwyanewang))
- **Create reusable terminal profiles from Host settings**
- **Open workspaces in Antigravity** ([#1424](https://github.com/getpaseo/paseo/pull/1424) by [@krumpyzoid](https://github.com/krumpyzoid))
### Improved
- Claude skills appear in prompt autocomplete as you type ([#1464](https://github.com/getpaseo/paseo/pull/1464))
- Copy file paths directly from file preview tab menus ([#1473](https://github.com/getpaseo/paseo/pull/1473))
- PR status stays current after an agent merges a branch ([#1455](https://github.com/getpaseo/paseo/pull/1455))
- Workspace tabs stay fast by retaining only the active workspace screens ([#1472](https://github.com/getpaseo/paseo/pull/1472))
### Fixed
- Composer send shortcuts no longer conflict with other keyboard shortcuts
- Multi-question prompts advance one answer at a time ([#1462](https://github.com/getpaseo/paseo/pull/1462))
- Imported Pi sessions keep their original model and thinking settings ([#1441](https://github.com/getpaseo/paseo/pull/1441) by [@thomasaull](https://github.com/thomasaull))
- Reconnecting to a desktop host keeps the saved shell and workspace route
- Worktree terminals no longer appear in parent workspaces
- Mobile reconnects show the welcome screen correctly
## 0.1.93 - 2026-06-10
### Added
- **Claude Fable 5 is available in the Claude model picker** ([#1443](https://github.com/getpaseo/paseo/pull/1443) by [@0-Captain](https://github.com/0-Captain))
## 0.1.92 - 2026-06-10
### Added
- **Skills autocomplete inside prompts**
### Improved
- Provider catalog is inline in Host settings ([#1423](https://github.com/getpaseo/paseo/pull/1423))
- Manual update checks skip staged rollout delays
- CodeWhale replaces DeepSeek TUI in the provider catalog
- ACP provider catalog entries are updated for Cline, Codebuddy Code, DimCode, Factory Droid, Gemini, Nova, and Qoder
- OMP has its own icon and website page
- Model selector descriptions are clearer
- ACP provider errors show the provider's real failure message
### Fixed
- New Paseo worktree branches can push their first commits
- Imported sessions no longer open blank or in the wrong workspace
- Windows Explorer opens the selected workspace instead of Documents ([#1412](https://github.com/getpaseo/paseo/pull/1412) by [@bjspi](https://github.com/bjspi))
- Windows editor shortcuts installed as command shims launch correctly ([#1387](https://github.com/getpaseo/paseo/pull/1387) by [@Peter7896](https://github.com/Peter7896))
- ACP providers that cannot use MCP servers can start correctly
- Removed hosts no longer leave host pages stuck connecting
- File preview links open in your external browser
- Chat stays pinned to the latest message while output streams
- The mobile composer send button no longer shifts while typing
## 0.1.91 - 2026-06-08
### Added
- **Open multiple desktop windows** ([#1355](https://github.com/getpaseo/paseo/pull/1355) by [@arieel-ost](https://github.com/arieel-ost))
- **Open browser pop-ups and links inside workspace tabs** ([#1375](https://github.com/getpaseo/paseo/pull/1375))
- **Use the command center from mobile**
- **Add OMP as a provider** ([#1388](https://github.com/getpaseo/paseo/pull/1388))
### Improved
- New workspaces remember your last provider, mode, and thinking choices
- Git controls now default ready branches to pull requests and hide unavailable pull or push actions
- Desktop-managed hosts recover more reliably after stale daemon state
- Daemon status now explains authentication failures
- Project search skips Python virtual environments ([#1356](https://github.com/getpaseo/paseo/pull/1356))
- Config files can include `$schema` for editor help
- Claude MCP servers preserve always-load tool settings ([#1333](https://github.com/getpaseo/paseo/pull/1333) by [@nodomain](https://github.com/nodomain))
- Claude profiles keep their configured models ([#1311](https://github.com/getpaseo/paseo/pull/1311) by [@ilteoood](https://github.com/ilteoood))
- Provider loading can wait longer on slow machines ([#1346](https://github.com/getpaseo/paseo/pull/1346) by [@nodomain](https://github.com/nodomain))
- The Kimi catalog entry now points to Kimi Code CLI ([#1403](https://github.com/getpaseo/paseo/pull/1403) by [@wbxl2000](https://github.com/wbxl2000))
- ACP provider catalog entries are updated for Auggie, Claude Agent, Cline, Codebuddy Code, DimCode, Factory Droid, fast-agent, Gemini, GitHub Copilot, and Nova
- Local dictation crash reports show more useful details ([#1379](https://github.com/getpaseo/paseo/pull/1379))
- Daemon logs show why managed workers exit
### Fixed
- Pi compaction slash commands run correctly ([#1338](https://github.com/getpaseo/paseo/pull/1338) by [@chyendongnhanh338](https://github.com/chyendongnhanh338))
- Auto-archiving still works after a merged PR branch is deleted ([#1378](https://github.com/getpaseo/paseo/pull/1378))
- Worktrees can check out existing branch refs correctly ([#1358](https://github.com/getpaseo/paseo/pull/1358) by [@dixonl90](https://github.com/dixonl90))
- File downloads work when daemon password protection is enabled ([#1351](https://github.com/getpaseo/paseo/pull/1351) by [@nodomain](https://github.com/nodomain))
- iOS markdown links are tappable again ([#1334](https://github.com/getpaseo/paseo/pull/1334) by [@kaspesi](https://github.com/kaspesi))
- iOS markdown images render correctly
- Windows workspaces load their providers correctly ([#1329](https://github.com/getpaseo/paseo/pull/1329))
- Removing a localhost host stops its local daemon ([#1297](https://github.com/getpaseo/paseo/pull/1297) by [@mcowger](https://github.com/mcowger))
- Provider settings sheets stack correctly
- The new workspace screen no longer opens behind the mobile sidebar
- Global agent listing works again ([#1420](https://github.com/getpaseo/paseo/pull/1420))
- OpenCode compaction summaries stay out of chat
- OpenCode agents sharing a workspace keep their own Paseo tools
## 0.1.90 - 2026-06-04
### Added
- **Group the sidebar by status so workspaces waiting on you, ready to review, working, and done are visible at a glance** ([#1317](https://github.com/getpaseo/paseo/pull/1317))
- **Start a new workspace from the global sidebar button without choosing a project first** ([#1324](https://github.com/getpaseo/paseo/pull/1324))
- **Open the active file directly in your editor, file manager, or GitHub instead of only opening the workspace root** ([#1285](https://github.com/getpaseo/paseo/pull/1285) by [@aaronzhongg](https://github.com/aaronzhongg))
- **Automatically archive clean PR workspaces after the PR is merged from host settings** ([#1313](https://github.com/getpaseo/paseo/pull/1313))
- **Desktop-managed Paseo skills stay current after installing a newer desktop build** ([#1309](https://github.com/getpaseo/paseo/pull/1309))
- **Dart files and Dart code blocks are now syntax-highlighted** ([#1326](https://github.com/getpaseo/paseo/pull/1326))
### Improved
- Sidebar workspaces can be marked as read when they are ready to review or failed ([#1317](https://github.com/getpaseo/paseo/pull/1317))
- Child agents keep unattended permissions when delegated across providers ([#1315](https://github.com/getpaseo/paseo/pull/1315))
- Scheduled agents open with the real prompt and title instead of looking empty ([#1316](https://github.com/getpaseo/paseo/pull/1316))
- Git controls prioritize the action that gets a ready branch shipped ([#1316](https://github.com/getpaseo/paseo/pull/1316))
- Multiple agent questions are shown one at a time
- OpenCode questions with free-write answers show the typed response in Paseo
- Delegated agent activity is visible on the parent workspace
- Sessions are ordered by latest activity
- ACP provider catalog entries are updated for Claude Agent, Cline, Codebuddy Code, Factory Droid, and Qoder
### Fixed
- Timeline catch-up no longer leaves older messages unloaded
- Markdown code in file previews renders correctly
- Long dictation retries no longer stall new audio
- Settings host picker navigation works from host settings pages
- Diff gutter rows stay aligned with changed code
- Mobile sidebar gestures stay responsive under load
- Compact sheets keep their footer and bottom spacing visible
## 0.1.89 - 2026-06-02
### Added
- **Open workspace services through public service proxy links** ([#1280](https://github.com/getpaseo/paseo/pull/1280) by [@mcowger](https://github.com/mcowger))
- **Choose where new worktrees are created** ([#1230](https://github.com/getpaseo/paseo/pull/1230) by [@mcowger](https://github.com/mcowger))
- **Desktop windows reopen at the same size and position** ([#1224](https://github.com/getpaseo/paseo/pull/1224) by [@everton-dgn](https://github.com/everton-dgn))
- **Delegated agents can run independently and send recurring heartbeat updates**
### Improved
- Composer controls fit better in narrow panes
- Fork pull request badges stay visible in worktrees
- Cline in the ACP catalog is updated to v3
### Fixed
- Archiving a worktree finishes even if teardown hits an error ([#1260](https://github.com/getpaseo/paseo/pull/1260) by [@mcowger](https://github.com/mcowger))
- iOS chat messages render bold, italics, strikethrough, and line breaks correctly ([#1254](https://github.com/getpaseo/paseo/pull/1254) by [@outofrange-consulting](https://github.com/outofrange-consulting))
- Right-edge split pane resizing no longer clips ([#1261](https://github.com/getpaseo/paseo/pull/1261) by [@everton-dgn](https://github.com/everton-dgn))
- Pi extension command output no longer hangs
- Delegated agents no longer appear in workspace alert counts
## 0.1.88 - 2026-06-01
### Added
- **Choose an app theme from the new Appearance settings**
- **Set a custom interface font**
- **Set a custom code font**
- **Adjust the interface text size**
- **Adjust the code text size**
- **Choose a syntax highlighting theme**
- **Keep cron schedules aligned to a chosen time zone** ([#1232](https://github.com/getpaseo/paseo/pull/1232) by [@damselem](https://github.com/damselem))
### Improved
- Settings now has a flatter sidebar with a host picker
- Workspace tab switching is faster
- Compact composers now show context usage as a percentage
- Agent terminals opened in workspace subdirectories now appear with the rest of the workspace terminals
- macOS displays can idle normally while the desktop app is open ([#1242](https://github.com/getpaseo/paseo/pull/1242) by [@fireblue](https://github.com/fireblue))
- Large generated diffs now show a clear too-large placeholder instead of trying to render the whole file
### Fixed
- Chat history catches up correctly around long-running tool updates
- Terminal panes keep the right size after splitting or resizing panes
- Restored terminal snapshots reflow correctly after the pane size changes
- Workspace scripts menus keep the right size after launching a service
- iOS chat messages no longer hide inline links, URLs, or linked file paths ([#1257](https://github.com/getpaseo/paseo/pull/1257) by [@outofrange-consulting](https://github.com/outofrange-consulting))
## 0.1.87 - 2026-05-30
### Added

View File

@@ -21,34 +21,46 @@ This is an npm workspace monorepo:
At the start of non-trivial work, list `docs/` and skim anything relevant to the task. When you learn something meta worth preserving — a gotcha, a convention, a workflow, a piece of system context that will outlive the current task — update an existing doc or propose a new one. Code-level facts belong in inline comments next to the code; system, process, and gotcha-level facts belong in `docs/`.
| Doc | What's in it |
| -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| [docs/product.md](docs/product.md) | What Paseo is, who it's for, where it's going |
| [docs/architecture.md](docs/architecture.md) | System design, package layering, WebSocket protocol, agent lifecycle, data flow |
| [docs/agent-lifecycle.md](docs/agent-lifecycle.md) | Agent states, parent/child relationships, archive semantics, tabs vs archive, subagents track |
| [docs/data-model.md](docs/data-model.md) | File-based JSON persistence, Zod schemas, atomic writes, no migrations |
| [docs/glossary.md](docs/glossary.md) | Authoritative terminology — UI label wins, no synonyms |
| [docs/coding-standards.md](docs/coding-standards.md) | Type hygiene, error handling, state design, React patterns, file organization |
| [docs/design.md](docs/design.md) | Theme tokens — colors, fonts, spacing, radii, icons |
| [docs/hover.md](docs/hover.md) | Hover — the canonical pattern (plain View + onPointerEnter/Leave, separate inner Pressable) and the three ways agents break it |
| [docs/unistyles.md](docs/unistyles.md) | Unistyles gotchas — `useUnistyles()` is forbidden, alternatives in order |
| [docs/floating-panels.md](docs/floating-panels.md) | Anchored popovers — Portal/Modal escape for Android, lifecycle gates, keyboard-shared-value, status-bar offset, the flash |
| [docs/file-icons.md](docs/file-icons.md) | Material icon theme integration for the file explorer |
| [docs/providers.md](docs/providers.md) | Adding a new agent provider end-to-end |
| [docs/custom-providers.md](docs/custom-providers.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries |
| [docs/development.md](docs/development.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP |
| [docs/rpc-namespacing.md](docs/rpc-namespacing.md) | WebSocket RPC naming convention — dotted namespaces and `.request`/`.response` pairs |
| [docs/testing.md](docs/testing.md) | TDD workflow, determinism, real dependencies over mocks, test organization |
| [docs/mobile-testing.md](docs/mobile-testing.md) | Maestro and mobile test workflows |
| [docs/ad-hoc-daemon-testing.md](docs/ad-hoc-daemon-testing.md) | Isolated in-process daemon test harness |
| [docs/android.md](docs/android.md) | App variants, local/cloud builds, EAS workflows |
| [docs/release.md](docs/release.md) | Release playbook, draft releases, completion checklist |
| [SECURITY.md](SECURITY.md) | Relay threat model, E2E encryption, DNS rebinding, agent auth |
| Doc | What's in it |
| ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------ |
| [docs/product.md](docs/product.md) | What Paseo is, who it's for, where it's going |
| [docs/architecture.md](docs/architecture.md) | System design, package layering, WebSocket protocol, agent lifecycle, data flow |
| [docs/agent-lifecycle.md](docs/agent-lifecycle.md) | Agent states, parent/child relationships, archive semantics, tabs vs archive, subagents track |
| [docs/data-model.md](docs/data-model.md) | File-based JSON persistence, Zod schemas, atomic writes, no migrations |
| [docs/glossary.md](docs/glossary.md) | Authoritative terminology — UI label wins, no synonyms |
| [docs/coding-standards.md](docs/coding-standards.md) | Type hygiene, error handling, state design, React patterns, file organization |
| [docs/design.md](docs/design.md) | Theme tokens — colors, fonts, spacing, radii, icons |
| [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/expo-router.md](docs/expo-router.md) | Expo Router route ownership, startup restore, and native blank-screen gotchas |
| [docs/file-icons.md](docs/file-icons.md) | Material icon theme integration for the file explorer |
| [docs/providers.md](docs/providers.md) | Adding a new agent provider end-to-end |
| [docs/forge-providers.md](docs/forge-providers.md) | Adding a git forge: registry/manifest, drop-in checklist, self-host/GHES, the two facts tiers |
| [docs/custom-providers.md](docs/custom-providers.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries |
| [docs/service-proxy.md](docs/service-proxy.md) | Service proxy: exposing workspace scripts at public URLs, DNS setup, reverse proxy config |
| [docs/development.md](docs/development.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP |
| [docs/rpc-namespacing.md](docs/rpc-namespacing.md) | WebSocket RPC naming convention — dotted namespaces and `.request`/`.response` pairs |
| [docs/protocol-validation.md](docs/protocol-validation.md) | zod-aot generated inbound WebSocket validation, patched compiler regressions, schema-purity rules |
| [docs/terminal-performance.md](docs/terminal-performance.md) | Terminal latency pipeline, coalescing/backpressure invariants, benchmark + perf spec usage |
| [docs/testing.md](docs/testing.md) | TDD workflow, determinism, real dependencies over mocks, test organization |
| [docs/mobile-testing.md](docs/mobile-testing.md) | Maestro and mobile test workflows |
| [docs/mobile-panels.md](docs/mobile-panels.md) | Compact left/center/right panel ownership, worklet motion, gesture revisions, and Fabric constraints |
| [docs/ad-hoc-daemon-testing.md](docs/ad-hoc-daemon-testing.md) | Isolated in-process daemon test harness |
| [docs/browser-capture-harness.md](docs/browser-capture-harness.md) | Real-Electron browser screenshot harness and compositor-surface gotcha |
| [docs/android.md](docs/android.md) | App variants, local/cloud builds, EAS workflows |
| [docs/docker.md](docs/docker.md) | Running the daemon and bundled web UI in Docker, volumes, agent images, security |
| [docs/release.md](docs/release.md) | Release playbook, draft releases, completion checklist |
| [docs/terminal-activity.md](docs/terminal-activity.md) | Terminal activity indicators — source-agnostic tracker, agent hook reporting, adding a new hook provider |
| [SECURITY.md](SECURITY.md) | Relay threat model, E2E encryption, DNS rebinding, agent auth |
## Quick start
```bash
npm run dev # Start daemon + Expo in Tmux
npm run dev # Start the dev daemon
npm run dev:app # Start Expo against the dev daemon
npm run dev:desktop # Start Electron desktop dev
npm run cli -- ls -a -g # List all agents
npm run cli -- daemon status # Check daemon status
npm run typecheck # Always run after changes
@@ -57,6 +69,8 @@ npm run format # Auto-format with Biome
npm run format:check # Check formatting without writing
```
Repo dev commands use checkout-local state by default. In this checkout, `PASEO_HOME` resolves to `.dev/paseo-home`, and `npm run cli -- ...` targets that same dev home automatically. The packaged desktop app and production-style daemon keep using `~/.paseo` on port `6767`.
See [docs/development.md](docs/development.md) for full setup, build sync requirements, and debugging.
## Critical rules
@@ -64,6 +78,7 @@ See [docs/development.md](docs/development.md) for full setup, build sync requir
- **NEVER restart the main Paseo daemon on port 6767 without permission** — it manages all running agents. If you're an agent, restarting it kills your own process.
- **NEVER assume a timeout means the service needs restarting** — timeouts can be transient.
- **NEVER add auth checks to tests** — agent providers handle their own auth.
- **Before changing app routes, startup routing, remembered workspace restore, or active workspace selection, read [docs/expo-router.md](docs/expo-router.md).**
- **NEVER run the full test suite locally.** The test suites are heavy and will freeze the machine, especially if multiple agents run them in parallel. Rules:
- Run only the specific test file you changed: `npx vitest run <file> --bail=1`
- Never run `npm run test` for an entire workspace unless explicitly asked.
@@ -81,10 +96,13 @@ See [docs/development.md](docs/development.md) for full setup, build sync requir
- `npm run format:files -- CLAUDE.md packages/app/src/components/message.tsx`
- **The protocol stays backward-compatible. Features don't have to.** Two separate contracts:
- **Protocol contract (always):** schema changes must not break parsing in either direction. An old client must still parse messages from a new daemon; a new daemon must still parse messages from an old client.
- New fields: `.optional()` with a sensible default or `.transform()` fallback.
- New fields: `.optional()` with a sensible default.
- Never flip optional → required, remove fields, or narrow types (`string``enum`, `nullable` → non-null).
- Removed fields stay accepted (we stop sending them, not stop reading them).
- Test with: "does a 6-month-old client still parse this?" and "does a 6-month-old daemon still send something this client accepts?"
- Wire schemas are pure structural declarations. Do not add `.transform()`, `.catch()`, or `.preprocess()` to WebSocket message schemas; put normalization in an explicit post-validation pass.
- Plain `z.union()` is forbidden when every branch has a shared literal tag. Use `z.discriminatedUnion()` unless generated-code regression tests prove that specific shape is miscompiled.
- `.default()` is acceptable on primitive leaves only. Never put defaults on item schemas for large arrays or big inbound containers.
- **Feature contract (per-feature):** a new feature may require a new daemon capability. The client detects whether the capability is present and either runs the feature or shows "Update the host to use this." That's it.
- **No fallback paths.** Don't write a degraded version of a new feature that runs on old daemons. Don't fan out across legacy RPCs to simulate a missing capability. The user upgrades or doesn't get the feature.
- **No defensive branches scattered through the feature.** Capability detection happens in one place; downstream code reads a clean shape.

View File

@@ -1,14 +1,28 @@
# Contributing to Paseo
Paseo is an opinionated product maintained by one person.
Paseo is an opinionated product maintained by one person right now.
I read every issue and PR myself, and I am selective about what contributions I accept.
The product covers a lot of surface: mobile, desktop, web, the daemon, the relay, and both self-hosted and hosted setups.
Good ideas still need to fit the shape of the product: a PR can be technically correct and still not belong in Paseo.
Contributing 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.
Core product, design, architecture, and workflow changes are not accepted.
## Becoming a maintainer
Follow these rules if you want your PR to be merged:
There's no formal process to become a maintainer, if you consistently contribute and help out, you'll become one.
Here's the progression:
1. Get involved in the community: answer questions in Discord and on GitHub
2. Triage bugs: replicate and help fix them
3. Work on maintainer-approved features
The reason for this progression is so that you can gain all the context you need to take on more responsibility, so that I can see if you have what it takes to be a maintainer.
Learning on the job is fine, I do not care how many years of experience you have, what I care about is that you get the vision and want to contribute.
## Pull requests
✅ Will be accepted
- Keep it to one focused change
- Link to an issue
@@ -18,15 +32,25 @@ Follow these rules if you want your PR to be merged:
- UI changes need screenshots or video for every affected platform: iOS, Android, desktop, and web
- If you only tested one platform, say that clearly
Your PR will be closed if you do any of these:
⛔️ Will be rejected
- Bundle unrelated changes
- Fail basic checks like typecheck, formatting or linting
- Make product, design, or architecture changes without prior discussion
- Add a feature or design change that wasn't discussed first
- Submit no evidence of testing
- Skip the linked issue
- Clearly fully AI-generated PR
## Requesting features
If you need a feature implemented, create a Github issue or a thread in Discord.
Explain the problem you want to solve: your use case, where Paseo falls short today, and the flow you expect.
## AI assistance
AI in the loop is fine. The bar is whether _you_ tested the change and can explain why it works. A confident wall of AI prose with no evidence of testing is a red flag and will get closed.
Using AI to help write code is fine, but you must:
- Ensure your agents read the docs
- Understand the code you submit
- Review and test the code yourself

173
README.ja.md Normal file
View File

@@ -0,0 +1,173 @@
<p align="center">
<img src="packages/website/public/logo.svg" width="64" height="64" alt="Paseo logo">
</p>
<h1 align="center">Paseo</h1>
<p align="center">
<a href="README.md">English</a> ·
<a href="README.zh-CN.md">简体中文</a> ·
<a href="README.ja.md">日本語</a>
</p>
<p align="center">
<a href="https://github.com/getpaseo/paseo/stargazers">
<img src="https://img.shields.io/github/stars/getpaseo/paseo?style=flat&logo=github" alt="GitHub stars">
</a>
<a href="https://github.com/getpaseo/paseo/releases">
<img src="https://img.shields.io/github/v/release/getpaseo/paseo?style=flat&logo=github" alt="GitHub release">
</a>
<a href="https://x.com/moboudra">
<img src="https://img.shields.io/badge/%40moboudra-555?logo=x" alt="X">
</a>
<a href="https://discord.gg/jz8T2uahpH">
<img src="https://img.shields.io/badge/Discord-555?logo=discord" alt="Discord">
</a>
<a href="https://www.reddit.com/r/PaseoAI/">
<img src="https://img.shields.io/badge/Reddit-555?logo=reddit" alt="Reddit">
</a>
</p>
<p align="center">Claude Code、Codex、Copilot、OpenCode、Pi のエージェントを、ひとつのインターフェースで。</p>
<p align="center">
<img src="https://paseo.sh/hero-mockup.png" alt="Paseo アプリのスクリーンショット" width="100%">
</p>
<p align="center">
<img src="https://paseo.sh/mobile-mockup.png" alt="Paseo モバイルアプリ" width="100%">
</p>
> [!NOTE]
> 私はひとりでメンテナンスしているため、GitHub Issues を毎日確認できるとは限りません。
> 急ぎの問題や作業がブロックされている場合は、[Discord](https://discord.gg/jz8T2uahpH) から連絡するのが一番早いです。
---
自分のマシンでエージェントを並列実行。スマートフォンからでもデスクからでも、開発を進めてリリースできます。
- **セルフホスト:** エージェントはあなたのマシン上で動作し、完全な開発環境を使用します。自分のツール・設定・スキルをそのまま活用できます。
- **マルチプロバイダー:** Claude Code、Codex、Copilot、OpenCode、Pi を同一のインターフェースで利用。タスクに合ったモデルを選べます。
- **音声コントロール:** 音声モードでタスクを口述したり問題を話し合ったりできます。ハンズフリーが必要なときに便利です。
- **クロスデバイス:** iOS、Android、デスクトップ、Web、CLI に対応。机で作業を始め、スマートフォンで確認し、ターミナルから自動化できます。
- **プライバシー優先:** Paseo にはテレメトリー・トラッキング・強制ログインは一切ありません。
## はじめかた
Paseo はコーディングエージェントを管理するローカルサーバーデーモンを起動します。デスクトップアプリ・モバイルアプリ・Web アプリ・CLI などのクライアントがこのデーモンに接続します。
### 前提条件
エージェント CLI をひとつ以上インストールし、認証情報を設定しておく必要があります。
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code)
- [Codex](https://github.com/openai/codex)
- [GitHub Copilot](https://github.com/features/copilot/cli/)
- [OpenCode](https://github.com/anomalyco/opencode)
- [Pi](https://pi.dev)
### デスクトップアプリ(推奨)
[paseo.sh/download](https://paseo.sh/download) または [GitHub のリリースページ](https://github.com/getpaseo/paseo/releases)からダウンロードしてください。アプリを開くとデーモンが自動的に起動します。追加のインストールは不要です。
スマートフォンから接続するには、Settings 画面に表示される QR コードをスキャンしてください。
### CLI / ヘッドレス
CLI をインストールして Paseo を起動します。
```bash
npm install -g @getpaseo/cli
paseo
```
ターミナルに QR コードが表示されます。どのクライアントからでも接続できます。サーバーやリモートマシンでの利用に適しています。
詳しいセットアップと設定については以下を参照してください。
- [ドキュメント](https://paseo.sh/docs)
- [設定リファレンス](https://paseo.sh/docs/configuration)
## 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-loop` — 明確な受け入れ基準に沿ってエージェントをループさせますRalph loops とも呼ばれます)。検証役を追加することもできます。
- `/paseo-advisor` — 単一のエージェントをアドバイザーとして起動し、作業を委任せずにセカンドオピニオンを得ます。
- `/paseo-committee` — 対照的な2つのエージェントで委員会を構成し、一歩引いた視点で根本原因を分析して計画を作成します。
## 開発
モノレポのパッケージ構成:
- `packages/server`: Paseo デーモンエージェントプロセスのオーケストレーション、WebSocket API、MCP サーバー)
- `packages/app`: Expo クライアントiOS、Android、Web
- `packages/cli`: デーモンおよびエージェントワークフロー向け `paseo` CLI
- `packages/desktop`: Electron デスクトップアプリ
- `packages/relay`: リモート接続用リレーパッケージ
- `packages/website`: マーケティングサイトとドキュメント(`paseo.sh`
よく使うコマンド:
```bash
# すべてのローカル開発サービスを起動
npm run dev
# 個別のサービスを起動
npm run dev:server
npm run dev:app
npm run dev:desktop
npm run dev:website
# サーバースタックをビルド
npm run build:server
# リポジトリ全体のチェック
npm run typecheck
```
## コミュニティ
- [paseo-relay](https://github.com/zenghongtu/paseo-relay) — Go 実装のセルフホスト型リレー
- [paseo-vscode](https://marketplace.visualstudio.com/items?itemName=hinnes.paseo-vscode) — VS Code 拡張機能
---
<p align="center">
<a href="https://star-history.com/#getpaseo/paseo&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=getpaseo/paseo&type=Date&theme=dark">
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=getpaseo/paseo&type=Date">
<img src="https://api.star-history.com/svg?repos=getpaseo/paseo&type=Date" alt="getpaseo/paseo のスター履歴チャート" width="600" style="max-width: 100%;">
</picture>
</a>
</p>
## ライセンス
AGPL-3.0

View File

@@ -4,6 +4,12 @@
<h1 align="center">Paseo</h1>
<p align="center">
<a href="README.md">English</a> ·
<a href="README.zh-CN.md">简体中文</a> ·
<a href="README.ja.md">日本語</a>
</p>
<p align="center">
<a href="https://github.com/getpaseo/paseo/stargazers">
<img src="https://img.shields.io/github/stars/getpaseo/paseo?style=flat&logo=github" alt="GitHub stars">
@@ -32,6 +38,10 @@
<img src="https://paseo.sh/mobile-mockup.png" alt="Paseo mobile app" width="100%">
</p>
> [!NOTE]
> I'm a solo maintainer and don't always keep up with GitHub Issues daily.
> If something is urgent or blocking you, [Discord](https://discord.gg/jz8T2uahpH) is the fastest place to reach me.
---
Run agents in parallel on your own machines. Ship from your phone or your desk.
@@ -60,7 +70,7 @@ You need at least one agent CLI installed and configured with your credentials:
Download it from [paseo.sh/download](https://paseo.sh/download) or the [GitHub releases page](https://github.com/getpaseo/paseo/releases). Open the app and the daemon starts automatically. Nothing else to install.
To connect from your phone, scan the QR code shown in Settings.
To connect from your phone, open **Settings → your host → Connections → Pair a device**.
### CLI / headless
@@ -78,6 +88,21 @@ For full setup and configuration, see:
- [Docs](https://paseo.sh/docs)
- [Configuration reference](https://paseo.sh/docs/configuration)
### Docker
Run the Paseo daemon and self-hosted web UI in Docker:
```bash
docker run -d --name paseo \
-p 6767:6767 \
-e PASEO_PASSWORD=change-me \
-v "$PWD/paseo-home:/home/paseo" \
-v "$PWD:/workspace" \
ghcr.io/getpaseo/paseo:latest
```
Open `http://localhost:6767` after it starts. Extend the base image with the agent CLIs you use, then provide credentials through environment variables or the persistent `/home/paseo` volume. See the [Docker documentation](docs/docker.md) for full setup details.
## CLI
Everything you can do in the app, you can do from the terminal.
@@ -144,52 +169,7 @@ npm run typecheck
## Community
- [paseo-relay](https://github.com/zenghongtu/paseo-relay) — self-hosted relay in Go
### Self-hosted relay TLS
Self-hosted relays use `ws://` unless TLS is opted in. For a relay behind nginx on 443, start the daemon with:
```bash
PASEO_RELAY_ENDPOINT=127.0.0.1:8080 \
PASEO_RELAY_PUBLIC_ENDPOINT=relay.example.com:443 \
PASEO_RELAY_USE_TLS=true \
paseo daemon start
```
Equivalent config:
```json
{
"daemon": {
"relay": {
"enabled": true,
"endpoint": "127.0.0.1:8080",
"publicEndpoint": "relay.example.com:443",
"useTls": true
}
}
}
```
Minimal nginx WebSocket proxy:
```nginx
server {
listen 443 ssl;
server_name relay.example.com;
ssl_certificate /etc/letsencrypt/live/relay.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/relay.example.com/privkey.pem;
location /ws {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
}
}
```
- [paseo-vscode](https://marketplace.visualstudio.com/items?itemName=hinnes.paseo-vscode) — VS Code extension
---

219
README.zh-CN.md Normal file
View File

@@ -0,0 +1,219 @@
<p align="center">
<img src="packages/website/public/logo.svg" width="64" height="64" alt="Paseo logo">
</p>
<h1 align="center">Paseo</h1>
<p align="center">
<a href="README.md">English</a> ·
<a href="README.zh-CN.md">简体中文</a> ·
<a href="README.ja.md">日本語</a>
</p>
<p align="center">
<a href="https://github.com/getpaseo/paseo/stargazers">
<img src="https://img.shields.io/github/stars/getpaseo/paseo?style=flat&logo=github" alt="GitHub stars">
</a>
<a href="https://github.com/getpaseo/paseo/releases">
<img src="https://img.shields.io/github/v/release/getpaseo/paseo?style=flat&logo=github" alt="GitHub release">
</a>
<a href="https://x.com/moboudra">
<img src="https://img.shields.io/badge/%40moboudra-555?logo=x" alt="X">
</a>
<a href="https://discord.gg/jz8T2uahpH">
<img src="https://img.shields.io/badge/Discord-555?logo=discord" alt="Discord">
</a>
<a href="https://www.reddit.com/r/PaseoAI/">
<img src="https://img.shields.io/badge/Reddit-555?logo=reddit" alt="Reddit">
</a>
</p>
<p align="center">Claude Code、Codex、Copilot、OpenCode 和 Pi agents 的统一界面。</p>
<p align="center">
<img src="https://paseo.sh/hero-mockup.png" alt="Paseo app screenshot" width="100%">
</p>
<p align="center">
<img src="https://paseo.sh/mobile-mockup.png" alt="Paseo mobile app" width="100%">
</p>
> [!NOTE]
> 我是独立维护者,不一定每天都能及时处理 GitHub Issues。
> 如果问题很紧急或阻塞了你,[Discord](https://discord.gg/jz8T2uahpH) 是最快联系到我的地方。
---
在你自己的机器上并行运行 agents。无论在手机上还是桌前都能推进交付。
- **自托管:** Agents 在你的机器上运行,使用完整的本地开发环境、工具、配置和技能。
- **多提供商:** 通过同一个界面使用 Claude Code、Codex、Copilot、OpenCode 和 Pi。为每个任务选择合适的模型。
- **语音控制:** 在语音模式下口述任务或讨论问题。需要免手操作时很方便。
- **跨设备:** 支持 iOS、Android、桌面端、Web 和 CLI。在桌前开始工作用手机查看进度也可以从终端脚本化操作。
- **隐私优先:** Paseo 没有遥测、追踪,也不会强制登录。
## 快速开始
Paseo 会运行一个名为 daemon 的本地服务,用来管理你的 coding agents。桌面 app、移动 app、Web app 和 CLI 等客户端都会连接到它。
### 前置条件
你至少需要安装一个 agent CLI并用你的凭据完成配置
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code)
- [Codex](https://github.com/openai/codex)
- [GitHub Copilot](https://github.com/features/copilot/cli/)
- [OpenCode](https://github.com/anomalyco/opencode)
- [Pi](https://pi.dev)
### 桌面 app推荐
从 [paseo.sh/download](https://paseo.sh/download) 或 [GitHub releases 页面](https://github.com/getpaseo/paseo/releases)下载。打开 app 后 daemon 会自动启动,不需要再安装其他东西。
如果要从手机连接,在 Settings 中扫描显示的二维码。
### CLI / 无头模式
安装 CLI 并启动 Paseo
```bash
npm install -g @getpaseo/cli
paseo
```
终端中会显示一个二维码。你可以从任意客户端连接。这个方式适合服务器和远程机器。
完整安装和配置见:
- [文档](https://paseo.sh/docs)
- [配置参考](https://paseo.sh/docs/configuration)
## CLI
你能在 app 中完成的事情,也都可以在终端中完成。
```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 # 列出正在运行的 agents
paseo attach abc123 # 实时流式查看输出
paseo send abc123 "also add tests" # 发送后续任务
# 在远程 daemon 上运行
paseo --host workstation.local:6767 run "run the full test suite"
```
更多内容见[完整 CLI 参考](https://paseo.sh/docs/cli)。
## Skills
Skills 会教你的 agent 使用 Paseo 来编排其他 agents。
```bash
npx skills add getpaseo/paseo
```
然后在任意 agent 对话中使用:
- `/paseo-handoff` — 在 agents 之间交接工作。我会用它先和 Claude 规划,再交给 Codex 实现。
- `/paseo-loop` — 让 agent 按明确验收标准循环工作(也叫 Ralph loops也可以加 verifier。
- `/paseo-advisor` — 启动单个 agent 作为 advisor提供第二意见但不把工作委托出去。
- `/paseo-committee` — 组建两个风格互补的 agents让它们后退一步做根因分析并产出计划。
## 开发
Monorepo 包结构速览:
- `packages/server`Paseo daemonagent 进程编排、WebSocket API、MCP server
- `packages/app`Expo 客户端iOS、Android、Web
- `packages/cli`:用于 daemon 和 agent 工作流的 `paseo` CLI
- `packages/desktop`Electron 桌面 app
- `packages/relay`:用于远程连接的 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
# 构建 server stack
npm run build:server
# 全仓库检查
npm run typecheck
```
## 社区
- [paseo-relay](https://github.com/zenghongtu/paseo-relay) — Go 实现的自托管 relay
- [paseo-vscode](https://marketplace.visualstudio.com/items?itemName=hinnes.paseo-vscode) — VS Code 扩展
### 自托管 relay TLS
自托管 relay 默认使用 `ws://`,除非显式启用 TLS。对于 nginx 后面、监听 443 的 relay可以这样启动 daemon
```bash
PASEO_RELAY_ENDPOINT=127.0.0.1:8080 \
PASEO_RELAY_PUBLIC_ENDPOINT=relay.example.com:443 \
PASEO_RELAY_USE_TLS=true \
paseo daemon start
```
等价配置:
```json
{
"daemon": {
"relay": {
"enabled": true,
"endpoint": "127.0.0.1:8080",
"publicEndpoint": "relay.example.com:443",
"useTls": true
}
}
}
```
最小 nginx WebSocket 代理配置:
```nginx
server {
listen 443 ssl;
server_name relay.example.com;
ssl_certificate /etc/letsencrypt/live/relay.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/relay.example.com/privkey.pem;
location /ws {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
}
}
```
---
<p align="center">
<a href="https://star-history.com/#getpaseo/paseo&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=getpaseo/paseo&type=Date&theme=dark">
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=getpaseo/paseo&type=Date">
<img src="https://api.star-history.com/svg?repos=getpaseo/paseo&type=Date" alt="Star history chart for getpaseo/paseo" width="600" style="max-width: 100%;">
</picture>
</a>
</p>
## License
AGPL-3.0

View File

@@ -50,6 +50,10 @@ Connected clients are trusted operators of the daemon user. File previews follow
If you expose the daemon beyond loopback, such as by binding to `0.0.0.0`, forwarding it through a tunnel or reverse proxy, or publishing it from a Docker container, you are responsible for restricting and securing that access. Setting a password is strongly recommended in that case.
In Docker, the official image runs the daemon and agents as the non-root
`paseo` user by default. Mounted workspaces and credentials are still fully
available to anything the agents run inside the container.
For remote access, use the relay connection. It is the supported path for reaching the daemon off-machine, and it adds end-to-end encryption plus a pairing handshake before commands are accepted.
Host header validation and CORS origin checks are defense-in-depth controls for localhost exposure. They help block DNS rebinding and browser-based attacks, but they do not replace network isolation.
@@ -64,6 +68,10 @@ Paseo validates the `Host` header on every HTTP request and every WebSocket upgr
Paseo wraps agent CLIs (Claude Code, Codex, OpenCode) but does not manage their authentication. Each agent provider handles its own credentials. Paseo never stores or transmits provider API keys. Agents run in your user context with your existing credentials.
## Forge host trust
Paseo only talks to a forge host that is either a known cloud host or one the forge CLI is already authenticated to. It never probes or routes credentials to an unauthenticated, remote-derived host.
## Reporting vulnerabilities
If you discover a security vulnerability, please report it privately by emailing hello@moboudra.com. Do not open a public issue.

View File

@@ -0,0 +1,17 @@
# Example child image that adds agent CLIs to the official Paseo image.
#
# Build:
# docker build -f docker/Dockerfile.agents.example -t paseo-with-agents .
#
# Then set `image: paseo-with-agents` in docker/docker-compose.example.yml.
FROM ghcr.io/getpaseo/paseo:latest
USER root
RUN npm install -g \
@anthropic-ai/claude-code \
@openai/codex \
opencode-ai
# Leave the image user as root. The base entrypoint prepares mounted volumes,
# then drops the daemon and launched agents to the non-root `paseo` user.

30
docker/README.md Normal file
View File

@@ -0,0 +1,30 @@
# Paseo Docker Image
This directory contains the official Paseo daemon image.
The image runs the daemon headless and serves the bundled web UI from the same
HTTP origin. Start it, then open the daemon URL in a browser.
```bash
docker run -d --name paseo \
-p 6767:6767 \
-e PASEO_PASSWORD=change-me \
-v "$PWD/paseo-home:/home/paseo" \
-v "$PWD:/workspace" \
ghcr.io/getpaseo/paseo:latest
```
Then open `http://localhost:6767`.
The base image intentionally does not bundle agent CLIs. Extend it with the
agents you use:
```Dockerfile
FROM ghcr.io/getpaseo/paseo:latest
USER root
RUN npm install -g @openai/codex @anthropic-ai/claude-code
```
See [docs/docker.md](../docs/docker.md) for Compose, reverse proxy, security,
agent auth, and troubleshooting notes.

104
docker/base/Dockerfile Normal file
View File

@@ -0,0 +1,104 @@
# syntax=docker/dockerfile:1
ARG NODE_IMAGE=node:22-bookworm-slim
FROM --platform=$BUILDPLATFORM ${NODE_IMAGE} AS source-pack
ARG PASEO_VERSION
ENV ONNXRUNTIME_NODE_INSTALL=skip
WORKDIR /tmp/paseo-src
COPY . .
RUN set -eux; \
if [ -n "${PASEO_VERSION:-}" ]; then \
test "$(node -p "require('./package.json').version")" = "${PASEO_VERSION}"; \
fi; \
node -e 'const fs=require("node:fs"); const pkg=JSON.parse(fs.readFileSync("package.json","utf8")); delete pkg.scripts.prepare; fs.writeFileSync("package.json", `${JSON.stringify(pkg)}\n`);'; \
npm ci
RUN set -eux; \
mkdir -p /tmp/paseo-packs; \
npm pack --workspace=@getpaseo/highlight --pack-destination /tmp/paseo-packs; \
npm pack --workspace=@getpaseo/relay --pack-destination /tmp/paseo-packs; \
npm pack --workspace=@getpaseo/protocol --pack-destination /tmp/paseo-packs; \
npm pack --workspace=@getpaseo/client --pack-destination /tmp/paseo-packs; \
npm pack --workspace=@getpaseo/server --pack-destination /tmp/paseo-packs; \
npm pack --workspace=@getpaseo/cli --pack-destination /tmp/paseo-packs
FROM ${NODE_IMAGE}
ENV HOME=/home/paseo \
PASEO_HOME=/home/paseo/.paseo \
PASEO_LISTEN=0.0.0.0:6767 \
PASEO_WEB_UI_ENABLED=true \
PASEO_LOG_FORMAT=json \
PASEO_LOG_LEVEL=info \
CLAUDE_CONFIG_DIR=/home/paseo/.claude \
CODEX_HOME=/home/paseo/.codex \
XDG_CONFIG_HOME=/home/paseo/.config \
XDG_DATA_HOME=/home/paseo/.local/share \
XDG_STATE_HOME=/home/paseo/.local/state \
XDG_CACHE_HOME=/home/paseo/.cache \
ONNXRUNTIME_NODE_INSTALL=skip
RUN set -eux; \
apt-get update; \
apt-get install -y --no-install-recommends \
bash \
ca-certificates \
curl \
git \
gosu \
lbzip2 \
openssh-client \
procps \
tini; \
rm -rf /var/lib/apt/lists/*
COPY --from=source-pack /tmp/paseo-packs /tmp/paseo-packs
RUN set -eux; \
npm install -g /tmp/paseo-packs/*.tgz; \
rm -rf /tmp/paseo-packs; \
npm cache clean --force; \
server_entry="$(npm root -g)/@getpaseo/server/dist/scripts/supervisor-entrypoint.js"; \
test -f "$server_entry"; \
printf '%s\n' "$server_entry" > /etc/paseo-server-entry; \
node --check "$server_entry"
RUN set -eux; \
existing_group="$(getent group 1000 | cut -d: -f1 || true)"; \
if [ -n "$existing_group" ] && [ "$existing_group" != "paseo" ]; then \
groupmod --new-name paseo "$existing_group"; \
elif [ -z "$existing_group" ]; then \
groupadd --gid 1000 paseo; \
fi; \
existing_user="$(getent passwd 1000 | cut -d: -f1 || true)"; \
if [ -n "$existing_user" ] && [ "$existing_user" != "paseo" ]; then \
usermod --login paseo --gid paseo --home /home/paseo --shell /bin/bash "$existing_user"; \
elif [ -z "$existing_user" ]; then \
useradd --uid 1000 --gid paseo --create-home --home-dir /home/paseo --shell /bin/bash paseo; \
fi; \
mkdir -p \
/workspace \
"$PASEO_HOME" \
"$CLAUDE_CONFIG_DIR" \
"$CODEX_HOME" \
"$XDG_CONFIG_HOME" \
"$XDG_DATA_HOME" \
"$XDG_STATE_HOME" \
"$XDG_CACHE_HOME"; \
chown -R paseo:paseo /home/paseo /workspace
COPY docker/base/rootfs/ /
RUN chmod +x /usr/local/bin/paseo-docker-entrypoint
WORKDIR /workspace
EXPOSE 6767
VOLUME ["/home/paseo"]
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
CMD node -e "const listen=process.env.PASEO_LISTEN||'0.0.0.0:6767'; const m=listen.match(/:(\\d+)$/); const port=m?Number(m[1]):6767; require('http').get({hostname:'127.0.0.1',port,path:'/api/health'},r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))"
ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/paseo-docker-entrypoint"]

View File

@@ -0,0 +1,78 @@
#!/usr/bin/env bash
set -euo pipefail
IMAGE_HOME="/home/paseo"
: "${HOME:=$IMAGE_HOME}"
: "${PASEO_HOME:=${HOME}/.paseo}"
: "${PASEO_LISTEN:=0.0.0.0:6767}"
: "${PASEO_WEB_UI_ENABLED:=true}"
: "${PASEO_LOG_LEVEL:=info}"
: "${PASEO_LOG_FORMAT:=json}"
: "${CLAUDE_CONFIG_DIR:=${HOME}/.claude}"
: "${CODEX_HOME:=${HOME}/.codex}"
: "${XDG_CONFIG_HOME:=${HOME}/.config}"
: "${XDG_DATA_HOME:=${HOME}/.local/share}"
: "${XDG_STATE_HOME:=${HOME}/.local/state}"
: "${XDG_CACHE_HOME:=${HOME}/.cache}"
export HOME
export PASEO_HOME
export PASEO_LISTEN
export PASEO_WEB_UI_ENABLED
export PASEO_LOG_LEVEL
export PASEO_LOG_FORMAT
export CLAUDE_CONFIG_DIR
export CODEX_HOME
export XDG_CONFIG_HOME
export XDG_DATA_HOME
export XDG_STATE_HOME
export XDG_CACHE_HOME
ensure_dir() {
local dir="$1"
mkdir -p "$dir"
if [[ "$(id -u)" == "0" ]]; then
local owner
owner="$(stat -c "%u" "$dir")"
if [[ "$owner" == "0" ]]; then
chown paseo:paseo "$dir"
fi
fi
}
ensure_dir "$HOME"
ensure_dir "$PASEO_HOME"
ensure_dir "$CLAUDE_CONFIG_DIR"
ensure_dir "$CODEX_HOME"
ensure_dir "$XDG_CONFIG_HOME"
ensure_dir "$XDG_DATA_HOME"
ensure_dir "$XDG_STATE_HOME"
ensure_dir "$XDG_CACHE_HOME"
if [[ "$#" -gt 0 ]]; then
if [[ "$(id -u)" == "0" ]]; then
exec gosu paseo "$@"
fi
exec "$@"
fi
if [[ -z "${PASEO_PASSWORD:-}" ]]; then
{
echo "[paseo] WARNING: PASEO_PASSWORD is not set."
echo "[paseo] The daemon accepts unauthenticated control connections from any client that can reach it."
echo "[paseo] Set PASEO_PASSWORD for any published port or network-reachable deployment."
} >&2
fi
if [[ ! -f /etc/paseo-server-entry ]]; then
echo "[paseo] FATAL: /etc/paseo-server-entry is missing." >&2
exit 1
fi
entry="$(cat /etc/paseo-server-entry)"
echo "[paseo] starting daemon on ${PASEO_LISTEN} with web UI ${PASEO_WEB_UI_ENABLED}"
if [[ "$(id -u)" == "0" ]]; then
exec gosu paseo node "$entry"
fi
exec node "$entry"

View File

@@ -0,0 +1,21 @@
# Minimal Paseo daemon + web UI deployment.
#
# Open http://localhost:6767 after `docker compose up -d`.
# For any network-reachable deployment, change PASEO_PASSWORD first.
services:
paseo:
image: ghcr.io/getpaseo/paseo:latest
container_name: paseo
restart: unless-stopped
ports:
- "6767:6767"
environment:
PASEO_PASSWORD: "change-me"
# Add DNS names you use to reach this container. IPs and localhost are
# already allowed by default.
# PASEO_HOSTNAMES: "paseo.example.com,.lan"
volumes:
# Persistent daemon state and agent credentials/config.
- ./paseo-home:/home/paseo
# Code visible to Paseo and the agents it launches.
- ./workspace:/workspace

View File

@@ -10,24 +10,59 @@ initializing → idle → running → idle (or error → closed)
└────────┘ (agent completes a turn, awaits next prompt)
```
Each agent in `AgentManager` carries a `lastStatus` of `initializing`, `idle`, `running`, `error`, or `closed`. State transitions persist to disk and stream to subscribed clients via WebSocket.
Each live agent in `AgentManager` carries a `lastStatus` of `initializing`, `idle`, `running`, or `error`. `closed` is the persisted, resumable state for an agent record that has no live provider runtime. State transitions persist to disk and stream to subscribed clients via WebSocket.
## Runtime residency
An unarchived agent may be `closed` without being deleted or archived. Closing releases its provider
processes and subscriptions while retaining its Paseo identity, persistence handle, timeline,
workspace, labels, title, usage, attention, timestamps, and parent relationship. Opening or prompting
the agent runs through `ensureAgentLoaded()`, which resumes the durable provider session under the
same Paseo agent ID. Provider history is not appended again when the canonical timeline is already
primed.
The daemon collects an eligible idle runtime after two minutes and sweeps every 15 seconds. Only
unarchived, non-internal agents that are exactly `idle`, have no active or pending run, replacement,
or permission, and have not been activated during the idle window are eligible. `running`,
`initializing`, and `error` agents stay resident. Subagents are considered independently; collection
does not cascade or change parentage.
Active schedules targeting an existing agent protect that agent from collection. Paused, completed,
and new-agent schedules do not. A pane may remain open after collection; its next prompt resumes the
runtime.
### 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.
## Relationships
Agents can launch other agents via the `create_agent` MCP tool. When they do, the daemon stamps the new agent with a label `paseo.parent-agent-id` pointing back at the caller (`packages/server/src/server/agent/mcp-server.ts:804`). The client surfaces that as `agent.parentAgentId`.
Agents can launch other agents via the agent-scoped `create_agent` MCP tool. Agent-scoped creation is always asynchronous and always stamps `paseo.parent-agent-id`, pointing back at the caller. Omit `workspaceId` to use the caller's workspace, or pass an existing workspace ID returned by `create_workspace`. Placement never changes parentage.
There is exactly one relationship type today: `parentAgentId`. The daemon does not distinguish between:
- **Subagents** — exist as part of the creating agent's work, appear in that agent's subagent track, and are archived with it.
- **Detached agents** — stand on their own after an explicit detach transition, do not appear in the former parent's subagent track, and are not archived with it.
- **Subagents** — children that exist as part of the parent's work (e.g. orchestration tasks the parent delegates and waits on)
- **Detached agents** — children launched to take over from the parent (e.g. handoffs, fire-and-forget delegations)
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.
Both look the same in storage. This is an accepted limitation — see [Limitations](#limitations).
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.
`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.
## Provider-managed child agents
Some providers can create their own child sessions inside one provider runtime. OMP's task tool reports these with `child_session` events; `AgentManager` imports the live provider handle, stamps `paseo.parent-agent-id`, and surfaces the result as a normal subagent in the parent's subagents track.
The provider still owns the underlying runtime. Paseo keeps an agent record so the child can be opened, tracked, archived, and cascaded with the parent, but prompts and history hydration route through the provider adapter for that native child handle.
## Archive
Archive is a **soft delete**: the agent record stays on disk with `archivedAt` set, the runtime is closed, and the agent disappears from active lists. Archive is **global** — it lives on the server and propagates to every connected client.
`create_agent_request` can opt an agent into `autoArchive`. In that mode the daemon archives the agent after the first terminal turn event (`turn_completed`, `turn_failed`, or `turn_canceled`). If the same request created a Paseo worktree through its `worktree` field, auto-archive archives that worktree too, which removes the agent records inside the worktree.
Archive is distinct from runtime collection. Archive sets `archivedAt`, invokes the provider's native
archive hook, and cascades to managed children. Runtime collection does none of those things; it only
releases the live runtime and writes `lastStatus: closed` on the still-active record.
`create_agent_request` can opt an agent into `autoArchive`. In that mode the daemon archives the agent after the first terminal turn event (`turn_completed`, `turn_failed`, or `turn_canceled`). When the agent owns an isolated workspace, auto-archive archives that workspace too; the managed worktree is removed when its final workspace reference is gone.
Archiving runs through `AgentManager.archiveAgent` (`packages/server/src/server/agent/agent-manager.ts`):
@@ -39,6 +74,12 @@ Archiving runs through `AgentManager.archiveAgent` (`packages/server/src/server/
Cascade is what keeps subagent fleets from outliving their orchestrator.
Workspace archive is a separate lifecycle. Archiving or removing a worktree can close a surviving
agent record without setting the agent's `archivedAt`, while its `workspaceId` still points at the
archived workspace. History navigation must not infer workspace lifecycle from `agent.archivedAt`
or mutate either lifecycle. The workspace route asks the daemon for authoritative recovery state;
only the route's explicit Unarchive or Restore action changes the archived workspace.
## Tabs vs archive
These are two distinct concepts that used to be conflated:
@@ -52,17 +93,35 @@ Closing a tab on a **root agent** still archives — the tab is the agent's home
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`).
The asymmetry is intentional: a subagent's home is the parent's track, not the tab. Tabs are ephemeral viewing slots; the track is the persistent record of the parent's children.
The asymmetry is intentional: a subagent's persistent relationship lives in the parent's track. Same-workspace subagents are not auto-opened as tabs; the user opens one from that track when needed. A cross-workspace subagent is also auto-opened as a tab in its own workspace so opening that workspace does not appear empty. It remains in the parent's track until it is actually detached.
## Workspace activity
Agent lifecycle status stays literal: a parent agent is `idle` when its own turn is idle, even if a child is running.
Workspace status is an aggregate activity signal computed **per `workspaceId`**. Ownership is never derived from `cwd` — many workspaces may share one directory, and same-`cwd` siblings do not clump under one status. Root agents and cross-workspace subagents contribute their normal state bucket to their own workspace. Same-workspace descendants contribute `running` to the nearest ancestor in that workspace; their non-running attention, permission, and error states stay in the parent's subagents track. This makes a cross-workspace subagent behave like a detached agent for workspace visibility and status without removing its parent relationship.
## The subagents track
The collapsible track above the composer in an agent's pane (`packages/app/src/subagents/track.tsx`). Membership rule (`packages/app/src/subagents/select.ts`):
The collapsible track above the composer in an agent's pane (`packages/app/src/subagents/track.tsx`) combines two kinds of children:
- **Paseo subagents** are full managed agents. Their membership rule (`packages/app/src/subagents/select.ts`) is:
```
parentAgentId === thisAgent.id AND !archivedAt
```
Archived subagents disappear from the track, by design. To remove a subagent from the track without closing its tab, use the **archive button (X)** on the row — it opens a confirm dialog and archives the subagent on confirm. That same archive shows the subagent leave the track on every connected client.
- **Provider subagents** are child executions owned by Claude, Codex, or OpenCode. They are not inserted into `AgentManager` as managed agents. Providers emit a separate descriptor and timeline stream through `agent.provider_subagents.*`; the client keeps that state outside the normal agent store and merges only the presentation rows into the track.
Clicking either kind opens a workspace tab. A Paseo subagent tab is a normal interactive agent pane. A provider subagent tab is a read-only timeline pane with no composer, archive, detach, rewind, or fork actions. Both panes use `AgentStreamView`, so message, reasoning, tool-call, and layout rendering stay identical.
Provider timelines use the same structural timeline item format but deliberately have a separate lifecycle and transport. A provider thread/session identifier is not a Paseo agent identifier, and closing its tab is always layout-only.
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.
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.
## Why this shape
@@ -71,21 +130,16 @@ The decision was to **decouple "close tab" from "archive" only for subagents**,
- **Closing a tab on a root agent still archives** — preserves the existing UX users are trained on
- **Closing a tab on a subagent is layout-only** — fixes the lossy "click to read, close to dismiss view, lose the row" flow
- **Archive button on track rows** — gives subagents an explicit lifecycle gesture in their home surface
- **Detach button on track rows** — lets a subagent continue independently without killing its work
- **Cascade archive on parent** — keeps subagents from leaking when the parent is archived
We considered universal decoupling (no tab close ever archives, archive is always explicit) but rejected it: it changes a behavior root-agent users rely on.
## Limitations
### Detached agents are cascade-archived
The daemon can't tell a "subagent" apart from a "detached agent" — both carry `paseo.parent-agent-id`. So when you archive an agent that previously launched a detached child (e.g. via `/paseo-handoff`), cascade will archive the detached child too, even though semantically it should outlive the originator.
Until a richer relation model lands (e.g. a `relation: "subagent" | "detached"` field on creation, or a separate channel for handoff launches), this trade-off stands. Workaround: don't archive an agent whose work was handed off, or unarchive the detached child afterward.
### Subagent accumulation under long-lived parents
A parent that spawns many subagents will see the track grow. There's no automatic cleanup for completed subagents — the user prunes via the archive button on each row. A bulk gesture (e.g. "archive all idle children") could land later if this becomes a real problem.
A parent that spawns many subagents will see the 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.
### Cross-client tab dismissal
@@ -97,13 +151,15 @@ Closing a subagent's tab on one client doesn't affect other clients' layouts. Th
$PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json
```
`{cwd-with-dashes}` is derived from the agent's filesystem `cwd`. It is not the workspace id; agent storage stays cwd-keyed while workspace identity is the opaque workspace id.
Each agent is a single JSON file. Fields relevant to this doc:
| Field | Type | Meaning |
| --------------------------------- | ------------- | ------------------------------------------------------------- |
| `id` | `string` | Stable identifier |
| `archivedAt` | `string?` | Soft-delete timestamp (ISO 8601) |
| `labels["paseo.parent-agent-id"]` | `string?` | Parent agent ID, set automatically by `create_agent` MCP tool |
| `lastStatus` | `AgentStatus` | `initializing` / `idle` / `running` / `error` / `closed` |
| Field | Type | Meaning |
| --------------------------------- | ------------- | ---------------------------------------------------------------------------------- |
| `id` | `string` | Stable identifier |
| `archivedAt` | `string?` | Soft-delete timestamp (ISO 8601) |
| `labels["paseo.parent-agent-id"]` | `string?` | Parent agent ID, set automatically for agent-scoped creation and removed by detach |
| `lastStatus` | `AgentStatus` | `initializing` / `idle` / `running` / `error` / `closed` |
See [`docs/data-model.md`](./data-model.md) for the full agent record.

View File

@@ -13,6 +13,50 @@ EAS profiles: `development`, `production`, and `production-apk` in `packages/app
`development` uses Android `debug`.
## Version codes
`packages/app/app.config.js` derives Android `versionCode` from the package version with:
```text
major * 1_000_000 + minor * 1_000 + patch
```
Prerelease metadata is ignored, so `0.1.102-beta.1` and `0.1.102` both produce `1102`. The same value is used as the iOS `buildNumber` because `packages/app/eas.json` uses EAS's local app version source. Do not re-enable EAS remote version counters or Android `autoIncrement`; F-Droid and other source-based builders need the native build number to be visible in the repo.
The formula reserves three digits each for minor and patch. If either reaches `1000`, change the formula before cutting that release.
## Prerequisites (local dev)
Local Android builds run on macOS (or Linux) and need the Android toolchain, pinned in `.tool-versions` (`java 21`, `android-sdk 21.0`) and wired up by `.mise.toml` (which sets `ANDROID_HOME` and puts `cmdline-tools/21.0/bin`, `platform-tools`, and `emulator` on `PATH`). With [mise](https://mise.jdx.dev):
```bash
mise install # java 21 + android-sdk 21.0 command-line tools
```
> **Pin a real `android-sdk` version, not `latest`.** The mise `android-sdk` plugin's `latest` resolved to the ancient `1.0` bundle, whose `sdkmanager` (3.6.0) predates the `emulator` package and fails with `Failed to find package emulator`. `21.0` ships a current `sdkmanager`. If you bump it, update the version in `.tool-versions` and in all four paths in `.mise.toml`.
`mise install` only lays down the command-line tools. Install the rest and create an emulator. On Apple Silicon:
```bash
sdkmanager --licenses
sdkmanager "platform-tools" "emulator" "platforms;android-35" "build-tools;35.0.0" \
"system-images;android-35;google_apis;arm64-v8a"
avdmanager create avd -n paseo -k "system-images;android-35;google_apis;arm64-v8a" -d pixel_7
emulator @paseo # start it; leave running
```
On an Intel Mac, use the `x86_64` system image:
```bash
sdkmanager --licenses
sdkmanager "platform-tools" "emulator" "platforms;android-35" "build-tools;35.0.0" \
"system-images;android-35;google_apis;x86_64"
avdmanager create avd -n paseo -k "system-images;android-35;google_apis;x86_64" -d pixel_7
emulator @paseo # start it; leave running
```
Gradle auto-fetches the platform/build-tools it needs once licenses are accepted, so adjust `android-35` only if it asks for a different level.
## Local build + install
From repo root:
@@ -38,6 +82,48 @@ npx cross-env APP_VARIANT=production expo run:android --variant=release
rm -rf android
```
## Running on an emulator against a worktree daemon
`npm run android` builds and installs the dev client, but two connections have to reach your Mac from inside the emulator — Metro (the JS bundle) and the Paseo daemon — and **the emulator does not share the host's loopback**: `localhost` inside the emulator is the emulator itself. Reach the host at `10.0.2.2` (the standard AVD's host alias) for both:
```bash
REACT_NATIVE_PACKAGER_HOSTNAME=10.0.2.2 \
EXPO_PUBLIC_LOCAL_DAEMON=10.0.2.2:$PASEO_SERVICE_DAEMON_PORT \
npm run android
```
- **`REACT_NATIVE_PACKAGER_HOSTNAME=10.0.2.2`** — without it, Expo bakes your Mac's LAN IP into the dev client's Metro URL, which the emulator can't route to, and the app dies with `Failed to connect to /<lan-ip>:8081` before any JS loads.
- **`EXPO_PUBLIC_LOCAL_DAEMON=10.0.2.2:<port>`** — the client's daemon endpoint (`packages/app/src/runtime/host-runtime.ts`); when unset it defaults to `localhost:6767`, the production daemon. Use `$PASEO_SERVICE_DAEMON_PORT` for a worktree daemon running as a Paseo service, or `6768` for a standalone `npm run dev:server`. It is inlined into the JS bundle at Metro bundle time, so set it on the build command and clear the Metro cache (`npx expo start -c`) if a change doesn't take.
**Alternative — `adb reverse` + `localhost`** (if `10.0.2.2` misbehaves):
```bash
adb reverse tcp:8081 tcp:8081
adb reverse tcp:$PASEO_SERVICE_DAEMON_PORT tcp:$PASEO_SERVICE_DAEMON_PORT
REACT_NATIVE_PACKAGER_HOSTNAME=localhost \
EXPO_PUBLIC_LOCAL_DAEMON=localhost:$PASEO_SERVICE_DAEMON_PORT \
npm run android
```
This is the Android counterpart of the iOS local-simulator flow in [development.md](development.md): on iOS the simulator shares the Mac's loopback so `localhost:<port>` works directly; on Android you need `10.0.2.2` or `adb reverse`.
## F-Droid / source-only Android builds
F-Droid builds should set `PASEO_FDROID_BUILD=1` when running Expo prebuild:
```bash
cd packages/app
PASEO_FDROID_BUILD=1 APP_VARIANT=production npx expo prebuild --platform android --clean --non-interactive
cd android
PASEO_FDROID_BUILD=1 ./gradlew assembleRelease --no-daemon --max-workers=1 -Dorg.gradle.parallel=false
```
The flag must be present for both prebuild and Gradle because Gradle starts Metro for the release bundle. Keep the source build serial and daemon-free as shown above: compiling every Expo module can exhaust memory when Gradle workers run in parallel. The profile enables source-built Expo modules, excludes the proprietary camera, Firebase notification, and Expo development-client native modules, disables EAS updates and Gradle dependency metadata, and substitutes JavaScript stubs for camera and notifications. The resulting app supports direct and pasted-link pairing but not QR scanning or push notifications.
Keep the excluded npm packages installed. Normal builds use them, while the F-Droid profile removes only their Android native modules and config plugins. Paseo always applies `expo-gradle-jvmargs` with `-Xmx4096m` and `-XX:MaxMetaspaceSize=1024m` so local Expo prebuilds have enough Gradle heap whether they use precompiled AARs or source-built Expo modules.
The EAS `production-apk` profile uses the large Android resource class. Release builds compile the native ABIs and run Hermes bundling in the same Gradle invocation; the default worker can exhaust its remaining memory and kill Hermes with exit code 137 even when Gradle's own heap is correctly sized.
### 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.

View File

@@ -48,26 +48,36 @@ The heart of Paseo. A Node.js process that:
- Listens for WebSocket connections from clients
- Manages agent lifecycle (create, run, stop, resume, archive)
- Streams agent output in real time via a timeline model
- Exposes an MCP server for agent-to-agent control
- Provides agent-to-agent tools through a transport-neutral tool catalog, with MCP as one adapter
- Optionally connects outbound to a relay for remote access
- Optionally serves the browser web client from the same HTTP server (self-hosting guide: [public-docs/web-ui.md](../public-docs/web-ui.md))
All paths are under `packages/server/src/`.
Project identity is daemon-global rather than session-owned. After registry bootstrap, the daemon's
project Git observer keeps one non-recursive watch on each lexically equivalent active project root
and listens only for the root `.git` entry, with a slow rescan as a missed-event fallback. It runs
for empty projects and without connected clients, then fans metadata changes through the WebSocket
server to capability-aware sessions. It deliberately does not use the broad recursive working-tree
watcher or the per-session Git observer: those are checkout/status mechanisms and intentionally do
not retain non-Git directories.
**Key modules:**
| Module | Responsibility |
| ------------------------------- | ---------------------------------------------------------------------------- |
| `server/bootstrap.ts` | Daemon initialization: HTTP server, WS server, agent manager, storage, relay |
| `server/websocket-server.ts` | WebSocket connection management, hello handshake, binary frame routing |
| `server/session.ts` | Per-client session state, timeline subscriptions, terminal operations |
| `server/agent/agent-manager.ts` | Agent lifecycle state machine, timeline tracking, subscriber management |
| `server/agent/agent-storage.ts` | File-backed JSON persistence at `$PASEO_HOME/agents/` |
| `server/agent/mcp-server.ts` | MCP server for sub-agent creation, permissions, timeouts |
| `server/agent/providers/` | Provider adapters (see "Agent providers" below) |
| `server/relay-transport.ts` | Outbound relay connection with E2E encryption |
| `server/schedule/` | Cron-based scheduled agents |
| `server/loop-service.ts` | Looping agent runs that retry until an exit condition |
| `server/chat/` | Chat rooms for agent-to-agent and human-to-agent messaging |
| Module | Responsibility |
| ------------------------------- | ----------------------------------------------------------------------------- |
| `server/bootstrap.ts` | Daemon initialization: HTTP server, WS server, agent manager, storage, relay |
| `server/websocket-server.ts` | WebSocket connection management, hello handshake, binary frame routing |
| `server/session.ts` | Per-client session state, timeline subscriptions, terminal operations |
| `server/agent/agent-manager.ts` | Agent lifecycle state machine, timeline tracking, subscriber management |
| `server/agent/agent-storage.ts` | File-backed JSON persistence at `$PASEO_HOME/agents/` |
| `server/agent/tools/` | Transport-neutral catalog for workspaces, agents, permissions, and automation |
| `server/agent/mcp-server.ts` | Thin MCP adapter that registers the Paseo tool catalog with the MCP SDK |
| `server/agent/providers/` | Provider adapters (see "Agent providers" below) |
| `server/relay-transport.ts` | Outbound relay connection with E2E encryption |
| `server/schedule/` | Cron-based scheduled agents |
| `server/loop-service.ts` | Looping agent runs that retry until an exit condition |
| `server/chat/` | Chat rooms for agent-to-agent and human-to-agent messaging |
### `packages/protocol` — Wire schemas and shared protocol types
@@ -81,20 +91,40 @@ it does not depend on the server.
Owns the low-level daemon WebSocket driver plus the higher-level `PaseoClient`
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`.
code imports from `@getpaseo/client`. Node automation uses
`@getpaseo/client/node`, which owns daemon discovery, authentication, transport
selection, and the capability-gated host automation facade shared by the CLI and
published import tools.
### External project import
The private [`getpaseo/import`](https://github.com/getpaseo/import) repository publishes
`@getpaseo/import`. It owns source discovery, parsing, mapping, fixtures, the CLI, and the
programmatic import engine. Paseo Desktop pins an exact package version and calls its library
entrypoint from Electron main, passing the public Node host-automation facade and forwarding typed
progress events through a narrow IPC bridge. Paseo does not contain source databases, parsers, or
import-package release machinery.
### `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.)
- 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.
- `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.
### `packages/cli` — Command-line client
Commander.js CLI with Docker-style commands. Common agent operations are also exposed at the top level (e.g. `paseo ls`, `paseo run`).
@@ -105,9 +135,11 @@ Commander.js CLI with Docker-style commands. Common agent operations are also ex
- `paseo terminal ls/create/capture/send-keys/kill`
- `paseo loop run/ls/inspect/logs/stop`
- `paseo schedule create/ls/inspect/update/pause/resume/run-once/logs/delete`
- `paseo heartbeat create/update/delete`
- `paseo workspace create/ls/archive`
- `paseo permit allow/deny/ls`
- `paseo provider ls/models`
- `paseo worktree create/ls/archive`
- hidden legacy `paseo worktree create/ls/archive` compatibility alias
- `paseo speech …`
Communicates with the daemon via the same WebSocket protocol as the app.
@@ -124,6 +156,11 @@ Enables remote access when the daemon is behind a firewall.
See [SECURITY.md](../SECURITY.md) for the full threat model.
### Paseo Hub
The optional Hub relationship is daemon-outbound and does not use the relay. Its connection,
authorization, ownership, persistence, and lifecycle contract is documented in [hub.md](hub.md).
### `packages/desktop` — Desktop app (Electron)
Electron wrapper for macOS, Linux, and Windows.
@@ -132,6 +169,30 @@ Electron wrapper for macOS, Linux, and Windows.
- Native file access for workspace integration
- Same WebSocket client as mobile app
**Multi-window (hybrid land-on model).** `createWindow()` in `main.ts` is reusable: `⌘⇧N`/File→New Window, relaunching the app (`second-instance`), and the sidebar "Open in new window" action each open a fresh `BrowserWindow`. Every window shows the full sidebar — there is no per-window project ownership or filtering. "Land on a project" is delivered by a per-`webContents` `PendingOpenProjectStore`: each window pulls its own pending project path on mount (`paseo:get-pending-open-project`) and runs the normal open-project flow, identical to a CLI `paseo <path>` launch.
> **Window-state v1 limitation:** only the _first_ window of a session restores and persists saved geometry (size/position/maximized). Windows opened via ⌘⇧N / second-instance / "Open in new window" open at the default size, OS-cascaded, and do not persist — this avoids every window stacking on the same restored bounds and fighting over the single window-state store. Lifting this needs per-window state keys.
>
> **In-app browser profile.** Every browser guest uses one stable persistent Electron session, so cookies, authentication, cache, and site storage are shared across tabs, workspaces, and desktop windows and survive tab or app closure. Browser identity is independent of that storage partition: after every `did-attach`, the renderer explicitly registers its browser id, workspace id, and current guest `WebContents` id, and main accepts the registration only when that guest belongs to the calling renderer and the shared profile. Registration is intentionally repeated because reparenting a retained `<webview>` can replace its guest without replacing the DOM element. Settings > General > Clear browser data is the sole profile-deletion path; it clears the shared session and reloads live guests without deleting saved tabs or URLs.
>
> **In-app browser window opens.** Ordinary link opens, including Shift-clicked links, become Paseo workspace tabs. Script-created opens with popup features or a named window target and POST-backed opens remain secured Electron child windows in the shared browser profile, preserving `window.opener`, `postMessage`, named-window reuse, request bodies, and `window.close()` for OAuth, payment, and similar popup protocols. Unsupported URL schemes are denied before either path.
>
> **In-app browser ownership.** Each registered guest records its owning host window. The active browser is keyed by `(host window, workspace)`, and application-menu Reload / Force Reload resolve only within the window Electron supplies to the menu callback. A non-null active update must name a browser owned by that host; a null update clears only that host/workspace. Browser automation continues to target explicit browser ids returned by `browser_new_tab` or `browser_list_tabs`.
>
> **Browser keyboard boundary.** Guest pages receive renderer-published shortcuts first. `Cmd/Ctrl+L` and `Cmd/Ctrl+R` are explicit guest-shell reservations; ordinary Paseo shortcuts run only after the page declines them. The sandboxed guest preload runs in every frame so focused iframes use the same boundary, while Node integration remains disabled. Human guest input disables Electron's menu fallback for plain keys. Agent-generated keys use guest `sendInputEvent` with `skipIfUnhandled`, so an unhandled Enter stops at the guest instead of reaching the host composer. Main selects the preload; it exposes no APIs to guest pages.
```text
Human key -> guest WebContents
|-- Cmd/Ctrl+T/L/R ----------> reserved browser-shell action
`-- page keydown
|-- page prevents ------> page owns it
`-- published shortcut -> guest preload -> IPC(browserId) -> Paseo resolver
Agent browser_keypress -> guest sendInputEvent(skipIfUnhandled)
|-- guest handles ------------> page owns it
`-- guest does not handle ----> stop; never redispatch to the host window
```
### `packages/website` — Marketing site
TanStack Router + Cloudflare Workers. Serves paseo.sh.
@@ -161,7 +222,9 @@ There is no dedicated welcome message; the server emits a `status` session messa
Client liveness checks use the top-level JSON `ping`/`pong` envelope, not a session RPC and not RFC6455 protocol ping. The app runs through browser and React Native WebSocket APIs, which do not expose protocol ping, so this envelope is the portable way to test the direct or relay data path. Session RPC timeouts are operation failures and must not be treated as proof that the socket is dead.
New session RPCs use dotted names with `.request` and `.response` suffixes, such as `checkout.github.set_auto_merge.request` and `checkout.github.set_auto_merge.response`. See [rpc-namespacing.md](rpc-namespacing.md) for the convention and migration rules for older flat RPC names.
Client session RPC waits default to 60s so slow relay or mobile networks do not turn a live but delayed daemon response into a false operation failure. Keep connect timeouts, app-level grace windows, explicit diagnostic latency probes, liveness ping timers, and genuinely long-running RPCs separate from this default.
New session RPCs use dotted names with `.request` and `.response` suffixes, such as `checkout.forge.set_auto_merge.request` and `checkout.forge.set_auto_merge.response`. See [rpc-namespacing.md](rpc-namespacing.md) for the convention and migration rules for older flat RPC names.
**Notable session message types:**
@@ -175,6 +238,15 @@ New session RPCs use dotted names with `.request` and `.response` suffixes, such
- Voice/dictation streaming events (`dictation_stream_*`, `assistant_chunk`, `audio_output`, `transcription_result`)
- Request/response pairs for fetch, list, create, etc., correlated by `requestId`; failures use `rpc_error`
`directory_suggestions_request` is one daemon-owned filesystem search capability. The daemon
configures the same `searchDirectoryEntries` engine with a root, output format, path-query policy,
entry-kind filters, match mode, blank-query behavior, and hidden-directory traversal policy. A
request without `cwd` searches the host home for absolute project paths; a request with `cwd`
searches that workspace and returns relative entries. Clients may prepend their small host-scoped
recent-project list for bare queries, but must not parse filesystem query syntax or re-filter a
correlated daemon response. The legacy `directories` response field remains a projection of the
typed `entries` list.
**Binary frames (terminal stream protocol):**
Terminal I/O is sent as binary WebSocket frames decoded by `decodeTerminalStreamFrame` in `shared/binary-frames/terminal.ts`. The layout is:
@@ -183,6 +255,8 @@ Terminal I/O is sent as binary WebSocket frames decoded by `decodeTerminalStream
- 1-byte slot: terminal slot id
- variable payload: bytes for output/input, JSON-encoded `{ rows, cols }` for resize, terminal snapshot for snapshot
Terminal PTY size is last-interacting-client-wins. A client claims the PTY size only when its terminal viewport genuinely changes size or the user focuses/taps the terminal. Passive rendering work — attaching, restoring visibility, font settling, renderer refits, or just looking at a visible terminal — must not send a resize frame. The server does not broadcast resize ownership; the resized PTY redraws through normal output, and every attached client renders that output in its own local viewport.
There is also a separate file-transfer binary frame format in the same directory, used for download/upload streams.
### Compatibility rules
@@ -224,13 +298,44 @@ initializing → idle ⇄ running
- Timeline is append-only with epochs (each run starts a new epoch). Storage uses sequence numbers for client-side dedup; the default fetch page is 200 items
- Timeline row `timestamp` values are canonical daemon-owned timestamps. Providers may supply original replay timestamps, but clients must not guess timestamp trust or hide time UI based on local clock heuristics.
- Events stream to connected clients in real time; correctness is backed by authoritative timeline fetches and paged-to-completion catch-up.
- Agent state persists to `$PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json` (timeline rows live alongside the record)
- Agent state persists to `$PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json` (timeline rows live alongside the record). That storage path is derived from `cwd`, not from workspace id.
## Right-sidebar boundary: directory-backed vs workspace-owned
Two workspaces can share the same `cwd` (e.g. a `directory` workspace and a `local_checkout` workspace on the same folder, or several workspaces opened against one checkout). Model B keeps these distinct: they share everything the directory determines, but nothing the workspace owns. The right-sidebar surfaces split cleanly along this line, and the split is enforced purely by **what each piece of state is keyed by**.
**Directory-backed (shared by same-`cwd` workspaces) — keyed by `(serverId, cwd)`, never by `workspaceId`:**
| Surface | Key | Source |
| ----------------------- | -------------------------------------------------------- | ------------------------------------------------------- |
| Git status | `checkoutStatusQueryKey(serverId, cwd)` | `packages/app/src/git/query-keys.ts` |
| Git diff | `checkoutDiffQueryKey(serverId, cwd, mode, baseRef, ws)` | `packages/app/src/git/query-keys.ts` |
| Forge change request | `checkoutPrStatusQueryKey(serverId, cwd)` | `packages/app/src/git/query-keys.ts` |
| Change request timeline | `prPaneTimelineQueryKey({ serverId, cwd, prNumber })` | `packages/app/src/git/pull-request-panel/query-keys.ts` |
| File preview content | `["workspaceFile", serverId, cwd, path]` | `packages/app/src/components/file-pane.tsx` |
| File explorer listings | fetched via `listDirectory(workspaceRoot, path)` | `packages/app/src/hooks/use-file-explorer-actions.ts` |
**Workspace-owned (independent per workspace) — keyed by `workspaceId` (falling back to `cwd` only when no `workspaceId` exists):**
| State | Key builder / store | Source |
| ---------------------------- | -------------------------------------------------- | ------------------------------------------------------------- |
| Review draft comments | `buildReviewDraftKey` / `buildReviewDraftScopeKey` | `packages/app/src/review/store.ts` |
| Diff mode override | review-draft scope key (in-memory) | `packages/app/src/review/state.ts` |
| Composer attachments | `buildWorkspaceAttachmentScopeKey` | `packages/app/src/attachments/workspace-attachments-store.ts` |
| File explorer nav/open state | `fileExplorer` map keyed `workspace:{workspaceId}` | `packages/app/src/hooks/use-file-explorer-actions.ts` |
| File explorer expanded paths | `expandedPathsByWorkspace[workspaceStateKey]` | `packages/app/src/stores/panel-store/state.ts` |
`diff-pane.tsx` is the canonical wiring site: it passes `{ serverId, cwd }` to the git queries and `{ serverId, workspaceId, cwd }` to the draft/override/attachment scope keys.
**Do not "fix" the sharing away.** Re-keying a directory-backed query by `workspaceId` makes same-`cwd` workspaces diverge (two windows onto the same git tree showing different diffs). Re-keying owned state (drafts, expanded paths) by `cwd` makes them leak between distinct workspaces on the same folder. The `workspaceId`-keyed builders carry a `// workspaceId is opaque; do not parse this key back into a path.` comment — the opaque-id fallback to `cwd` exists only for old payloads without a `workspaceId`, not as a content-sharing mechanism.
One deliberate non-violation: `AgentFileExplorerState.directories`/`files` cache directory listings inside the `workspaceId`-keyed explorer map. Same-`cwd` workspaces therefore keep duplicate caches, but they can never diverge — both fetch the identical directory via `listDirectory(workspaceRoot, …)`. This is duplication, not leakage, and is left as-is.
## Agent providers
Each provider implements the `AgentClient` interface in `agent/agent-sdk-types.ts`. Provider implementations live in `agent/providers/`.
The built-in, user-facing providers are Claude Code, Codex, Copilot, OpenCode, and Pi. Additional adapters exist in the same directory for ACP-compatible agents and internal use:
The built-in, user-facing providers are Claude Code, Codex, Copilot, OpenCode, Pi, and OMP. Additional adapters exist in the same directory for ACP-compatible agents and internal use:
| Provider | Wraps | Session format |
| ------------------ | ------------------------------------ | -------------------------------------------------- |
@@ -250,6 +355,8 @@ All providers:
- Map tool calls to a normalized `ToolCallDetail` type
- Expose provider-specific modes (plan, default, full-access)
Providers that can accept native tool definitions should set `supportsNativePaseoTools` and read `launchContext.paseoTools`. The daemon then passes the shared Paseo tool catalog directly and removes the internal Paseo MCP server from that provider launch config. Providers that only support MCP continue to receive the same tools through the MCP fallback at `/mcp/agents`.
## Data flow: running an agent
1. Client sends `CreateAgentRequestMessage` with config (prompt, cwd, provider, model, mode)

View File

@@ -0,0 +1,94 @@
# Browser Capture Harness
The desktop capture harness is the real-Electron verification path for browser screenshots.
It validates the compositor behavior that unit tests cannot see:
- the resident automation `<webview>` starts in the production parking state;
- the parked guest remains paintable and has a copyable viewport frame;
- the resident webview guest is sized to 1280x800 logical pixels;
- multiple resident webviews are parked as an overlapping stack without per-capture
stacking changes;
- a newly attached resident webview whose first useful frame is delayed can be captured
by retrying until the frame appears;
- both viewport `capturePage` and full-page CDP screenshots return real pixels from
the permanent production parking state;
- guest background throttling can be disabled once at attach without per-capture
renderer coordination;
- the real-Electron host-composer sentinel proves guest Enter cannot submit a focused
host composer;
- the automation group loads the compiled production keyboard boundary and guest
preload, then proves that initial page window handlers get first refusal, unhandled
shortcuts synchronously suppress editable browser defaults before crossing the host
boundary, shortcuts marked unavailable in editable targets retain the browser field's
native behavior, handlers registered after preload still get first refusal, focused
iframes share the same boundary, digit wildcard shortcuts cross, and background automation
stays in the guest.
Run it with the repo Electron:
```bash
npm run capture-harness --workspace=@getpaseo/desktop
```
Build the desktop main process before the automation group so its production guest
preload is available:
```bash
npm run build:main --workspace=@getpaseo/desktop
PASEO_CAPTURE_HARNESS_GROUP=automation npm run capture-harness --workspace=@getpaseo/desktop
```
Run the shared browser profile fixture with:
```bash
PASEO_CAPTURE_HARNESS_GROUP=browser-profile npm run capture-harness --workspace=@getpaseo/desktop
```
The browser profile group runs two Electron processes in sequence. It verifies that each
renderer-side `did-attach` identity maps to the correct main-process guest, that two live
tabs share cookies and local storage through one persistent session, and that the data is
still present after the first Electron process exits and the second starts.
The automation group uses a real guest webview to verify the page-side ref contract:
ARIA-like snapshot text includes headings, static text, and controls; refs survive
`pushState` when the element still matches; same-URL rerenders stale old refs; and a
file-input ref can be resolved to a CDP backend node id for upload. It also verifies
page-context evaluation, including passing a resolved ref element as the function argument.
Keyboard containment runs last because the host-composer sentinel intentionally leaves
native focus in the host. It reuses an existing fixture button: adding a test-only control
changes the inline fixture geometry exercised by the earlier actionability checks.
On macOS the harness process must set `app.setActivationPolicy("accessory")` and
hide the Dock icon before creating any window. `showInactive()` only prevents window
focus; a normal Electron app launch can still activate the app and steal focus.
Harness windows are then created hidden, positioned in a screen corner, skipped from
the taskbar where Electron supports it, and revealed with `showInactive()` from
`ready-to-show`. Do not replace this with `show()`, `focus()`, or `app.focus()`:
the compositor only needs visible inactive windows, and harness runs must not steal
focus from the person using the machine.
The harness writes PNG evidence and `results.json` to:
```text
packages/desktop/capture-harness/out/
```
A passing run prints `PASS` lines for the production P1 attach-off parking state,
including fresh, settled, 75-second soak, multi-tab, viewport, and full-page checks. The
PNG sizes may be device-pixel scaled; on a Retina display the 1280x800 logical viewport
is usually saved as 2560x1600.
## Mechanism
Electron captures copy from the guest web contents' compositor surface. A resident
webview parked with `display:none`, offscreen coordinates, or `opacity:0` can lose its
copyable surface. The production parking state keeps the host fixed at `left:0`, `top:0`,
`width:1px`, `height:1px`, `overflow:hidden`, `opacity:1`, and `pointer-events:none`.
The webviews inside stay full-size at 1280x800, `display:inline-flex`, and absolutely
overlap at `left:0`, `top:0`.
There is no renderer prep/restore handshake. Main disables guest background throttling
once when the webview attaches, then screenshot capture uses the shared serialized queue,
invalidates before each attempt, and retries known first-frame failures within the
5-second capture budget. Viewport screenshots use `capturePage({ stayHidden:false })`;
full-page screenshots use the existing CDP path with layout metrics and screenshot clip.

View File

@@ -47,6 +47,8 @@ For testing rules, see [testing.md](testing.md).
- Catch blocks branch on `instanceof` for what they can handle; rethrow the rest. No `catch (e) { return null }`.
- Separate user-facing copy from log/debug strings — don't make one string serve telemetry, logs, and the UI.
- Fail explicitly. If the caller asked for X and X isn't available, throw — don't silently substitute Y.
- Every fallible user action owns explicit pending, success, and failure UI. Console logs and unverified platform alerts do not satisfy this contract. See [testing.md](testing.md#fallible-user-actions).
- A capability advertised to a client means the current runtime can perform the action, not merely that its RPC handler exists. If an unavailable action needs explanatory UI, send the runtime fact or reason separately and keep the server-side refusal fail-closed.
## Density
@@ -84,6 +86,10 @@ For testing rules, see [testing.md](testing.md).
- Components render and dispatch — they don't compute transitions. Two-plus interacting `useState`s → extract a reducer.
- Never define components inside other components. Module-scope only.
- Subscribe narrowly: select primitives from stores, pass `status` not `agent`, use `useShallow` / deep-equal when returning derived arrays/objects.
- Collection rows do not independently subscribe to a high-frequency global store. The collection owner selects structurally shared indexes once, derives a keyed row model with `useMemo`, and passes entries to rows. This keeps retained hidden collections current without running one selector per row on every store update.
- Equality functions prevent React renders; they do not prevent selector callbacks from running. A selector attached to a hot store must be O(1) when its relevant source references have not changed.
- Retained native panels use `RetainedPanel`. If an existing gesture/layout wrapper must own visibility, wrap its contents in `RetainedPanelActivity` instead. Keep keyed panel roots in a stable sibling order, include the newly active panel in the same render, centralize subscriptions, and gate genuine effects through `useRetainedPanelActive`. Do not use `Suspense` or render freezing for this on native: those techniques change native tree ownership instead of merely stopping work.
- Infinite animations are subscriptions. Start them only while their retained panel is active, and cancel a shared clock when its final active consumer leaves. Synchronized animations use one clock per animation family and feed active instances through local shared values; retained hidden instances stay mounted but unsubscribed. Match state updates to the actual visual cadence; do not run every style worklet at 60 fps when the rendered value changes only a few times per second.
- Stable references for props that cross `memo` boundaries or feed dependency arrays. Static literals at module scope `as const`; derived with `useMemo`; handlers with `useCallback` only when there's a memoized beneficiary.
- Use stable ids for `key`, never array index for reorderable/filterable lists.
- Context for stable values (theme, auth). Store with selectors for state that changes.

View File

@@ -35,7 +35,7 @@ Provider IDs must be lowercase alphanumeric with hyphens (`/^[a-z][a-z0-9-]*$/`)
## Extending a built-in provider
Use `extends` to create a new provider entry that inherits from a built-in provider (claude, codex, copilot, opencode, pi). The new provider gets its own entry in the provider list, with its own label, environment, and model definitions.
Use `extends` to create a new provider entry that inherits from a built-in provider (claude, codex, copilot, opencode, pi, omp). The new provider gets its own entry in the provider list, with its own label, environment, and model definitions.
```json
{
@@ -171,7 +171,7 @@ For pay-as-you-go, use `ANTHROPIC_API_KEY` with a standard Model Studio key (`sk
| `qwen3-coder-next` | Optimized for coding |
| `kimi-k2.5` | Vision capable |
| `glm-5` | Zhipu GLM |
| `MiniMax-M2.5` | MiniMax |
| `MiniMax-M3` | MiniMax |
**Additional models (pay-as-you-go):**
`qwen3-max`, `qwen3.5-flash`, `qwen3-coder-plus`, `qwen3-coder-flash`, `qwen3-vl-plus`, `qwen3-vl-flash`
@@ -347,6 +347,69 @@ Override the command used to launch any provider with the `command` field. This
The `command` array completely replaces the default command for that provider. The binary must exist on the system — Paseo checks for its availability and will mark the provider as unavailable if not found.
### OMP profiles and Pi-compatible forks
OMP ships as a first-class built-in provider option. It is disabled by default; enable it with:
```json
{
"agents": {
"providers": {
"omp": { "enabled": true }
}
}
}
```
Custom OMP profiles should extend `omp`. They inherit the OMP adapter's `rpc-ui` approvals, native Paseo host tools, provider-managed subagents, and import behavior:
```json
{
"agents": {
"providers": {
"omp-work": {
"extends": "omp",
"label": "Oh My Pi (Work)",
"command": ["omp"],
"env": {
"XDG_CONFIG_HOME": "~/.config/omp-work",
"XDG_STATE_HOME": "~/.local/state/omp-work"
},
"params": {
"sessionDir": "~/.local/state/omp-work/omp/agent/sessions",
"smolModel": "openai/gpt-5-mini",
"slowModel": "anthropic/claude-opus-4-1",
"planModel": "openai/o3"
}
}
}
}
}
```
`params.sessionDir` is used only for importing sessions that were started outside Paseo. If `command` or XDG env vars move OMP's state directory, set `params.sessionDir` to the resulting OMP JSONL session directory; launching and resuming still go through the configured command.
For other providers that keep Pi's `--mode rpc` API but write sessions somewhere else, extend `pi`, replace the command, and provide the JSONL session directory:
```json
{
"agents": {
"providers": {
"my-pi-fork": {
"extends": "pi",
"label": "My Pi Fork",
"command": ["my-pi-fork"],
"params": {
"sessionDir": "~/.my-pi-fork/sessions"
}
}
}
}
}
```
This session directory is also import-only. Launching and resuming still go through the configured command, so this example resumes with `my-pi-fork --mode rpc --session <session-file>`.
---
## Disabling a provider
@@ -364,7 +427,7 @@ Set `enabled: false` to hide a provider from the provider list. The provider wil
}
```
This works for both built-in and custom providers. To re-enable, set `enabled: true` or remove the `enabled` field entirely (providers are enabled by default).
This works for both built-in and custom providers. To re-enable, set `enabled: true` or remove the `enabled` field entirely. Most providers are enabled by default; OMP is intentionally disabled by default and requires `enabled: true`.
---
@@ -374,7 +437,7 @@ The [Agent Client Protocol (ACP)](https://agentclientprotocol.com) is an open st
ACP agents communicate over JSON-RPC 2.0 on stdio. Paseo spawns the agent process and talks to it through stdin/stdout.
Paseo also ships an in-app ACP provider catalog for common agents, including Cursor, DeepAgents, DeepSeek TUI, DimCode, Gemini CLI, Hermes, Qwen Code, and Kimi Code. Catalog entries create the same `extends: "acp"` provider config shown below.
Paseo also ships an in-app ACP provider catalog for common agents, including CodeWhale, Cursor, DeepAgents, DimCode, Gemini CLI, Hermes, Qwen Code, and Kimi Code. Catalog entries create the same `extends: "acp"` provider config shown below.
### Adding a generic ACP provider
@@ -403,6 +466,56 @@ Required fields for ACP providers:
- `label`
- `command` — the command to spawn the agent process (must support ACP over stdio)
Paseo tools such as subagent creation come from the shared internal tool catalog. ACP providers receive those tools through the MCP fallback by default because ACP exposes `mcpServers`, not Paseo's native tool catalog. Some ACP adapters cannot create sessions when `mcpServers` is non-empty. Disable injected MCP for those providers with `params.supportsMcpServers: false`:
```json
{
"agents": {
"providers": {
"my-agent": {
"extends": "acp",
"label": "My Agent",
"command": ["my-agent", "acp"],
"params": {
"supportsMcpServers": false
}
}
}
}
}
```
ACP agents execute filesystem and terminal operations in their own environment
by default. To let a compliant agent delegate those operations to Paseo instead,
enable the corresponding client capabilities:
```json
{
"agents": {
"providers": {
"local-agent": {
"extends": "acp",
"label": "Local Agent",
"command": ["local-agent", "acp"],
"params": {
"clientCapabilities": {
"fs": {
"readTextFile": true,
"writeTextFile": true
},
"terminal": true
}
}
}
}
}
}
```
Only enable capabilities Paseo should execute. When the agent and Paseo run in
different environments, configure equivalent absolute workspace paths before
delegating filesystem or terminal operations to Paseo.
### Generic ACP diagnostics
Paseo diagnostics for `extends: "acp"` providers report the configured command, resolved launcher binary, version output, ACP `initialize`, ACP `session/new`, model count, modes, and final status.
@@ -539,18 +652,19 @@ When an `additionalModels` entry has the same `id` as a discovered model, it upd
Every entry under `agents.providers` accepts these fields:
| Field | Type | Required | Description |
| ------------------ | ------------------------ | ----------------- | ------------------------------------------------------------------ |
| `extends` | `string` | Yes (custom only) | Built-in provider ID to inherit from, or `"acp"` |
| `label` | `string` | Yes (custom only) | Display name in the UI |
| `description` | `string` | No | Short description shown in the UI |
| `command` | `string[]` | Yes (ACP only) | Command to spawn the agent process |
| `env` | `Record<string, string>` | No | Environment variables to set for the agent process |
| `models` | `ProviderProfileModel[]` | No | Static model list (overrides runtime discovery) |
| `additionalModels` | `ProviderProfileModel[]` | No | Static model additions (merged with runtime discovery or `models`) |
| `disallowedTools` | `string[]` | No | Tool names to disable for this provider (e.g. `["WebSearch"]`) |
| `enabled` | `boolean` | No | Set to `false` to hide the provider (default: `true`) |
| `order` | `number` | No | Sort order in the provider list |
| Field | Type | Required | Description |
| ------------------ | ------------------------- | ----------------- | ------------------------------------------------------------------ |
| `extends` | `string` | Yes (custom only) | Built-in provider ID to inherit from, or `"acp"` |
| `label` | `string` | Yes (custom only) | Display name in the UI |
| `description` | `string` | No | Short description shown in the UI |
| `command` | `string[]` | Yes (ACP only) | Command to spawn the agent process |
| `env` | `Record<string, string>` | No | Environment variables to set for the agent process |
| `params` | `Record<string, unknown>` | No | Provider-specific options such as `supportsMcpServers: false` |
| `models` | `ProviderProfileModel[]` | No | Static model list (overrides runtime discovery) |
| `additionalModels` | `ProviderProfileModel[]` | No | Static model additions (merged with runtime discovery or `models`) |
| `disallowedTools` | `string[]` | No | Tool names to disable for this provider (e.g. `["WebSearch"]`) |
| `enabled` | `boolean` | No | Set to `false` to hide the provider (default: `true`) |
| `order` | `number` | No | Sort order in the provider list |
### Model definition
@@ -577,7 +691,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. `agents.providers.claude.models` is still supported and is additive for the built-in Claude provider; duplicate IDs are de-duplicated.
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.
### Gotcha: `extends: "claude"` with third-party endpoints
@@ -604,7 +718,7 @@ Use `disallowedTools` to disable unsupported tools:
### Valid `extends` values
Built-in providers: `claude`, `codex`, `copilot`, `opencode`, `pi`
Built-in providers: `claude`, `codex`, `copilot`, `opencode`, `pi`, `omp`
Special value: `acp` — creates a generic ACP provider (requires `command`)

View File

@@ -1,9 +1,37 @@
# Data Model
## Project identity
Projects are allocated for the exact root selected by the caller, normalized lexically with `path.resolve` (never `realpath`). New project IDs are opaque `prj_<16 hex>` values. Existing remote-shaped or path-shaped IDs are retained as readable compatibility records and are never rekeyed. An active exact root is idempotent; archived-only matches do not resurrect an old project. Workspace `projectId` is stable membership: reconciliation may update git-derived kind and branch metadata, but never rehomes a workspace or changes a project's root, ID, or default name.
`kind` is mutable metadata, not identity. Workspace reconciliation watches active project roots and
updates only a project's `kind` and `updatedAt` when `.git` appears or disappears, preserving its
ID, root path, names, and workspace foreign keys. Attached workspaces are independently refreshed
from their own cwd, so an explicit project root never implies a workspace checkout. Empty projects
are observed too.
The workspace registry model defines placement once: initial directory/worktree construction,
mutable reconciliation fields, and the persisted-to-wire checkout projection. Its update policy
preserves `displayName` and `baseBranch`. `WorkspaceProvisioningService` owns the corresponding
registry writes, so directory opens, agent imports, and worktree creation all enter through that
service instead of constructing records independently. The workspace record is then the durable
placement authority: `cwd` is the exact execution directory, while `worktreeRoot` is the backing
checkout root. They intentionally differ for an exact subproject inside a worktree. Archive,
restore, branch auto-name, and descriptor flows consume those persisted facts rather than
rediscovering ownership from a directory that may already be gone. Reconciliation may refresh
mutable placement facts, but never changes `projectId`, `cwd`, `displayName`, or `baseBranch`.
Workspace archive runs lifecycle teardown from the exact `cwd` but removes only the backing
`worktreeRoot` after its last active reference disappears. Worktree recovery recreates that backing
checkout from `mainRepoRoot`, then restores the relative path from `worktreeRoot` to `cwd`.
Paseo uses **file-based JSON persistence** instead of a traditional database. All data is validated at runtime with Zod schemas. Most stores write atomically (write to temp file, then rename); a few still use plain `writeFile` — see each section. There is no schema-versioning/migration framework — schemas rely on optional fields with defaults for forward compatibility, with a small amount of inline normalization in `persisted-config.ts` for legacy provider/speech entries.
All server-side stores live under `$PASEO_HOME` (defaults to `~/.paseo`).
## Store Surface Rules
Store APIs own persistence atomicity and should not make services coordinate raw reads and writes. A good store method maps cleanly to one SQL statement or one SQL transaction, even when the current implementation is JSON files. If a caller needs a queue, lock, read-merge-write loop, or uniqueness race workaround, that behavior belongs behind the store surface.
---
## Directory layout
@@ -27,10 +55,13 @@ $PASEO_HOME/
├── projects/
│ ├── projects.json # Project registry
│ └── workspaces.json # Workspace registry
├── runtime/
│ └── managed-processes/
│ └── {recordId}.json # Helper processes owned by Paseo; reconciled on daemon bootstrap
└── push-tokens.json # Expo push notification tokens
```
The `agents/{sanitized-cwd}/` directory name is derived from the agent's `cwd` by stripping the filesystem root and replacing path separators with `-` (Windows drive letters become a `C-` style prefix). Atomic writes (temp file + rename): agent records, chat, project/workspace registries, push tokens. Non-atomic (plain `writeFile`): `config.json`, `schedules/*.json`, `loops/loops.json`, `server-id`, `daemon-keypair.json`.
The `agents/{sanitized-cwd}/` directory name is derived from the agent's `cwd` by stripping the filesystem root and replacing path separators with `-` (Windows drive letters become a `C-` style prefix). Persistent server stores write atomically by writing a temp file in the target directory and then renaming it into place.
---
@@ -40,29 +71,30 @@ The `agents/{sanitized-cwd}/` directory name is derived from the agent's `cwd` b
Each agent is stored as a separate JSON file, grouped by project directory.
| Field | Type | Description |
| -------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id` | `string` | UUID, primary key |
| `provider` | `string` | Agent provider (`"claude"`, `"codex"`, `"opencode"`, etc.) |
| `cwd` | `string` | Working directory the agent operates in |
| `createdAt` | `string` (ISO 8601) | Creation timestamp |
| `updatedAt` | `string` (ISO 8601) | Last update timestamp |
| `lastActivityAt` | `string?` (ISO 8601) | Last activity timestamp |
| `lastUserMessageAt` | `string?` (ISO 8601) | Last user message timestamp |
| `title` | `string?` | User-visible title |
| `labels` | `Record<string, string>` | Key-value labels (default `{}`). `paseo.parent-agent-id` set automatically when launched via the `create_agent` MCP tool — see [agent-lifecycle.md](./agent-lifecycle.md) |
| `lastStatus` | `AgentStatus` | One of: `"initializing"`, `"idle"`, `"running"`, `"error"`, `"closed"` |
| `lastModeId` | `string?` | Last active mode ID |
| `config` | `SerializableConfig?` | Agent session configuration (see below) |
| `runtimeInfo` | `RuntimeInfo?` | Live runtime state (see below) |
| `features` | `AgentFeature[]?` | Provider-reported features (toggles/selects) |
| `persistence` | `PersistenceHandle?` | Handle for resuming sessions |
| `lastError` | `string?` (nullable) | Last error message, if any |
| `requiresAttention` | `boolean?` | Whether the agent needs user attention |
| `attentionReason` | `"finished" \| "error" \| "permission"?` | Why attention is needed |
| `attentionTimestamp` | `string?` (ISO 8601) | When attention was flagged |
| `internal` | `boolean?` | Whether this is a system-internal agent (loop workers, etc.) |
| `archivedAt` | `string?` (ISO 8601) | Soft-delete timestamp |
| Field | Type | Description |
| -------------------- | ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id` | `string` | UUID, primary key |
| `provider` | `string` | Agent provider (`"claude"`, `"codex"`, `"opencode"`, etc.) |
| `cwd` | `string` | Working directory the agent operates in |
| `workspaceId` | `string?` | Owning workspace id — the single source of ownership. Every agent is stamped with one at create time; legacy cwd-only records are backfilled once by `migrations/backfill-workspace-id.migration.ts` (the only place a cwd→id mapping exists). Runtime code never infers ownership or status from cwd: status is computed per `workspaceId`, and same-cwd siblings are independent. |
| `createdAt` | `string` (ISO 8601) | Creation timestamp |
| `updatedAt` | `string` (ISO 8601) | Last update timestamp |
| `lastActivityAt` | `string?` (ISO 8601) | Last activity timestamp |
| `lastUserMessageAt` | `string?` (ISO 8601) | Last user message timestamp |
| `title` | `string?` | User-visible title |
| `labels` | `Record<string, string>` | Key-value labels (default `{}`). `paseo.parent-agent-id` is set automatically for agent-scoped creation and removed by detach — see [agent-lifecycle.md](./agent-lifecycle.md) |
| `lastStatus` | `AgentStatus` | One of: `"initializing"`, `"idle"`, `"running"`, `"error"`, `"closed"`. `closed` means the record is resumable but has no live provider runtime; archive remains represented separately by `archivedAt`. |
| `lastModeId` | `string?` | Last active mode ID |
| `config` | `SerializableConfig?` | Agent session configuration (see below) |
| `runtimeInfo` | `RuntimeInfo?` | Live runtime state (see below) |
| `features` | `AgentFeature[]?` | Provider-reported features (toggles/selects) |
| `persistence` | `PersistenceHandle?` | Handle for resuming sessions |
| `lastError` | `string?` (nullable) | Last error message, if any |
| `requiresAttention` | `boolean?` | Whether the agent needs user attention |
| `attentionReason` | `"finished" \| "error" \| "permission"?` | Why attention is needed |
| `attentionTimestamp` | `string?` (ISO 8601) | When attention was flagged |
| `internal` | `boolean?` | Whether this is a system-internal agent (loop workers, etc.) |
| `archivedAt` | `string?` (ISO 8601) | Soft-delete timestamp |
### Nested: SerializableConfig
@@ -126,6 +158,14 @@ Each agent is stored as a separate JSON file, grouped by project directory.
---
## Runtime-only Terminal Sessions
Terminals are live daemon state, not persisted JSON records. A terminal carries a `workspaceId` while it is running; workspace-scoped terminal lists include only terminals with the matching `workspaceId`. Legacy live terminals without an owner remain visible to unscoped terminal reads but contribute to no workspace status.
Terminal activity contributes to the workspace status bucket **per `workspaceId`**: a working terminal drives `running` onto the workspace it carries only. Same-`cwd` siblings are untouched; terminal visibility is likewise `workspaceId`-scoped.
---
## 2. Daemon Configuration
**Path:** `$PASEO_HOME/config.json`
@@ -138,6 +178,7 @@ Single file, validated with `PersistedConfigSchema`.
daemon: {
listen: "127.0.0.1:6767",
hostnames: true | string[], // legacy alias `allowedHosts` is migrated on load
trustedProxies: true | string[], // defaults to ["loopback"]; Express proxy names/CIDRs
mcp: { enabled: boolean, injectIntoAgents: boolean },
appendSystemPrompt: string, // appended to supported provider system/developer prompts
cors: { allowedOrigins: string[] },
@@ -147,8 +188,16 @@ Single file, validated with `PersistedConfigSchema`.
app: {
baseUrl: string
},
worktrees?: {
root?: string // optional root for new worktrees; defaults to $PASEO_HOME/worktrees
},
providers: {
openai: { apiKey: string },
openai: {
apiKey?: string,
baseUrl?: string,
stt?: { apiKey?: string, baseUrl?: string },
tts?: { apiKey?: string, baseUrl?: string }
},
local: { modelsDir: string }
},
agents: {
@@ -178,13 +227,48 @@ All fields are optional with sensible defaults.
Local speech model ids are intentionally narrow: STT uses `parakeet-tdt-0.6b-v2-int8`, TTS uses `kokoro-en-v0_19`, and turn detection uses the bundled Silero VAD model.
Set these to select OpenAI instead of local speech:
| Env var | Applies to |
| ------------------------------ | ------------------------------- |
| `PASEO_VOICE_STT_PROVIDER` | Voice mode STT provider |
| `PASEO_DICTATION_STT_PROVIDER` | Composer dictation STT provider |
| `PASEO_VOICE_TTS_PROVIDER` | Voice mode TTS provider |
OpenAI speech can be configured under `providers.openai`. STT and TTS resolve independently, so they can point at different endpoints:
```json
{
"providers": {
"openai": {
"stt": {
"apiKey": "sk-...",
"baseUrl": "https://stt.example.com/v1"
},
"tts": {
"apiKey": "sk-...",
"baseUrl": "https://api.openai.com/v1"
}
}
}
}
```
`providers.openai.stt` is used for both composer dictation and voice mode speech-to-text; `providers.openai.tts` is used for voice mode text-to-speech. The equivalent env vars are `OPENAI_STT_API_KEY`/`OPENAI_STT_BASE_URL` and `OPENAI_TTS_API_KEY`/`OPENAI_TTS_BASE_URL`. Each feature falls back to `providers.openai.apiKey`/`providers.openai.baseUrl`, then `OPENAI_API_KEY`/`OPENAI_BASE_URL`, when its own fields are unset. These settings apply only to Paseo OpenAI speech features, not to Codex or other OpenAI-backed tools.
Paseo uses these paths under the configured OpenAI base URL:
- dictation STT: `/v1/audio/transcriptions`
- voice mode STT: `/v1/audio/transcriptions`
- voice mode TTS: `/v1/audio/speech`
---
## 3. Schedule
**Path:** `$PASEO_HOME/schedules/{id}.json`
One file per schedule. ID is 8 hex characters. Writes are direct (not atomic).
One file per schedule. ID is 8 hex characters.
| Field | Type | Description |
| ----------- | ------------------------------------- | -------------------------------- |
@@ -205,8 +289,8 @@ One file per schedule. ID is 8 hex characters. Writes are direct (not atomic).
### Nested: ScheduleCadence (discriminated union on `type`)
- `{ type: "every", everyMs: number }` — interval in milliseconds
- `{ type: "cron", expression: string }` — cron expression
- `{ type: "cron", expression: string, timezone?: string }` — canonical cadence for new writes; absent `timezone` means UTC
- `{ type: "every", everyMs: number }` — legacy rolling interval, still readable and executable during the compatibility window
### Nested: ScheduleTarget (discriminated union on `type`)
@@ -360,20 +444,24 @@ Single file containing an array of all loop records. Writes are direct (not atom
Array of project records.
| Field | Type | Description |
| ------------- | --------------------------- | ---------------------------------------- |
| `projectId` | `string` | Primary key |
| `rootPath` | `string` | Filesystem root of the project |
| `kind` | `"git" \| "non_git"` | |
| `displayName` | `string` | |
| `createdAt` | `string` (ISO 8601) | |
| `updatedAt` | `string` (ISO 8601) | |
| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete timestamp; required nullable |
| Field | Type | Description |
| ------------- | --------------------------- | -------------------------------------------------------------------------------- |
| `projectId` | `string` | Primary key; new records use opaque `prj_<16 hex>` IDs |
| `rootPath` | `string` | Exact lexically normalized selected root; never realpathed |
| `kind` | `"git" \| "non_git"` | Mutable Git observation about `rootPath`, never a membership key |
| `displayName` | `string` | Selected-root basename, stable across remote and Git changes |
| `customName` | `string \| null` | User-set override layered over `displayName`. Null means "use the derived name". |
| `createdAt` | `string` (ISO 8601) | |
| `updatedAt` | `string` (ISO 8601) | |
| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete timestamp; required nullable |
Active git projects are unique by normalized `rootPath`. Startup reconciliation repairs older bad
states by moving workspaces from duplicate path-keyed projects onto the canonical project,
preferring remote-keyed project IDs such as `remote:github.com/owner/repo`, then archiving the
emptied duplicate.
Active exact roots are idempotent using lexical platform-equivalence semantics. Existing legacy
remote-shaped and path-shaped IDs remain readable, including duplicate roots; reconciliation never
merges them, transfers names, archives them, or moves workspace foreign keys. An explicit
workspace `projectId` is authoritative when it names an active project, regardless of cwd
containment. Archived-only exact-root records are not resurrected by explicit add/open; a fresh
opaque project is allocated instead. Agent restore is separate and restores the agent's existing
workspace together with its owning project.
---
@@ -383,16 +471,30 @@ emptied duplicate.
Array of workspace records. A workspace is a specific working directory within a project.
| Field | Type | Description |
| ------------- | ----------------------------------------------- | ------------------------------ |
| `workspaceId` | `string` | Primary key |
| `projectId` | `string` | FK to Project.projectId |
| `cwd` | `string` | Filesystem path |
| `kind` | `"local_checkout" \| "worktree" \| "directory"` | |
| `displayName` | `string` | |
| `createdAt` | `string` (ISO 8601) | |
| `updatedAt` | `string` (ISO 8601) | |
| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete; required nullable |
| Field | Type | Description |
| ---------------------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `workspaceId` | `string` | Opaque stable identifier (`wks_<hex>`), generated independently of the directory. MUST NOT be treated as a path; compare by exact equality. Use the `cwd` field for directory access. |
| `projectId` | `string` | FK to Project.projectId; the workspace's stable project membership |
| `cwd` | `string` | Exact execution directory selected for agents, files, scripts, and setup |
| `kind` | `"local_checkout" \| "worktree" \| "directory"` | Mutable checkout classification |
| `displayName` | `string` | The human name (the generated/derived title). Decoupled from `branch` by construction. |
| `title` | `string \| null` | User-set name override layered over `displayName`. Null means "use `displayName`". |
| `branch` | `string \| null` | The current Git branch for git-backed workspaces. Separate from `displayName`/`title`; a background branch refresh never rewrites the name. |
| `worktreeRoot` | `string \| null` | Backing checkout/worktree root. May differ from `cwd` for exact subprojects and remains persisted after the worktree is deleted so restore can reproduce the placement. |
| `baseBranch` | `string \| null` | Normalized branch the Paseo worktree was created from; null for directories, local checkouts, and checkout-branch worktrees |
| `isPaseoOwnedWorktree` | `boolean` | Whether Paseo owns and may remove/recreate the backing `worktreeRoot` |
| `mainRepoRoot` | `string \| null` | Main repository root for worktree checkouts, independent of both exact `cwd` and backing `worktreeRoot` |
| `createdAt` | `string` (ISO 8601) | |
| `updatedAt` | `string` (ISO 8601) | |
| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete; required nullable |
| `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.
`projectId` is still a real FK: workspace records should have a matching project record. Read-only
history surfaces tolerate transient orphaned workspaces by omitting those rows so one bad FK cannot
blank the whole History screen, but mutation paths should repair or remove the orphaned state rather
than treating it as valid.
---
@@ -427,6 +529,13 @@ These small files are not validated as full Zod schemas but are persisted under
These live in React Native `AsyncStorage` or browser `IndexedDB`, not on the daemon filesystem.
### Keying convention: directory-backed vs workspace-owned
Right-sidebar client state splits on whether it is determined by the directory or owned by the workspace (two workspaces can share one `cwd`). The split is enforced by the cache key, so changing a key changes the sharing semantics — see [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned) for the full table.
- **Directory-backed** (shared by same-`cwd` workspaces): keyed by `(serverId, cwd)`. Git status/diff, GitHub PR status, PR timeline, file preview content. These are TanStack Query caches, not persisted stores.
- **Workspace-owned** (independent per workspace): keyed by `workspaceId`, with `cwd` used only as a fallback when no `workspaceId` is present. Review draft comments (`@paseo:review-draft-store`), diff-mode overrides (in-memory), workspace composer attachments, and file-explorer nav/expand state. The `workspaceId` part of these keys is **opaque** — never parse it back into a path.
### Draft Store
**AsyncStorage key:** `paseo-drafts` (version 2)

View File

@@ -40,6 +40,8 @@ The rule, condensed: text that _names_ a surface or a group is `medium`. Text th
Foreground is for the thing being acted on: row titles, section headings, the selected sidebar item. `foregroundMuted` is for context: hints, descriptions, secondary metadata, idle sidebar items, placeholders, status text.
`foregroundExtraMuted` is reserved for passive chrome that must sit behind muted text, such as an always-visible window control. Use the solid token instead of lowering SVG opacity; per-path opacity makes overlapping icon strokes render unevenly. Interactive hover and pressed states return to `foreground`.
Accent is the one CTA per surface. A `<Button variant="default">` filled with `accent` appears at most once on a page. Most pages have zero — settings is mostly toggles and text, the workspace pane is mostly content, the chat composer is the input itself.
Destructive is a color, not a click. Restart-daemon and remove-host are `<Button variant="outline">` in the row trailing slot; the destructive surface only appears inside the `confirmDialog` (`packages/app/src/screens/settings/host-page.tsx:541-547`). Workspace archive opens a confirm dialog before any red appears (`packages/app/src/components/sidebar-workspace-list.tsx`). Red appears after the user has indicated intent.
@@ -94,6 +96,8 @@ Five primitives. The pick is determined by option count, the need to search, and
`<AdaptiveModalSheet>` is for a focused task. Multi-field forms (`packages/app/src/components/add-host-modal.tsx`, `packages/app/src/components/pair-link-modal.tsx`, `packages/app/src/components/project-picker-modal.tsx`), confirmations with detail, anything that earns a backdrop. Bottom sheet on compact, centered card on desktop. Raw `Modal` is wrong for any of these.
`<AdaptiveModalSheet>` owns compact bottom safe-area padding inside the sheet so the sheet background still reaches the screen bottom. If a sheet's first snap point is shorter than its header, content, and safe-area clearance, raise that snap point rather than moving the sheet container.
`confirmDialog` is for destructive yes/no and imperative confirmation. Promise-based: `await confirmDialog({ destructive: true, ... })`. Anything where a wrong click loses work.
Three themes is `DropdownMenu`. Thirty hosts is `Combobox`. A label and a value is `AdaptiveModalSheet`. "Are you sure?" is `confirmDialog`.
@@ -129,6 +133,10 @@ The branching is one `useIsCompactFormFactor()` check at the top of the screen c
The workspace screen (`packages/app/src/screens/workspace/workspace-screen.tsx`) follows a different but parallel rule: tabs collapse on compact, panes split on desktop. The sidebar (`packages/app/src/components/left-sidebar.tsx`) is overlaid on compact and pinned on desktop.
On a narrow desktop route, app navigation yields to the rendered content topology when the remaining width cannot preserve its center target: Settings keeps its 320px list + 400px detail split, and a workspace Explorer keeps its current visible width plus a 400px center pane. That is a topology decision at the app container, not a second compact breakpoint. Temporary width clamps are render-only; widening restores the user's saved sidebar widths.
Electron window controls are top-corner obstructions, not a compact-layout condition. Rendered surfaces declare which top corners they physically occupy; only those corners receive clearance. Full-window overlays redeclare both corners. A focused split pane owns both corners; if focus restoration temporarily exposes the full split tree, the split boundary reserves one top strip instead of assigning a control rectangle to an arbitrarily narrow leaf. The 720px desktop breakpoint preserves the default 320px sidebar and target 400px center width when the Explorer is closed; it is product policy, not an obstruction gate.
A new list+detail feature copies the settings shell. A new workspace-shaped feature copies the workspace shell. Inventing a third shape happens in design review, not in a PR.
---
@@ -216,7 +224,7 @@ The bespoke pills in `packages/app/src/screens/settings/host-page.tsx:97-116`, `
- Raw DOM APIs without an `isWeb` guard.
- Spacing values outside the scale. `padding: 20` and `gap: 10` are wrong.
- Color changes for disabled state. Opacity only.
- Destructive actions without `confirmDialog`. Restart, remove, and future destructive actions are confirmed. Worktree archive is confirmed only when git runtime reports uncommitted changes or unpushed commits; clean pushed worktrees archive immediately.
- Destructive actions without `confirmDialog`. Restart, remove, and future destructive actions are confirmed. Archive workspace is confirmed only when its worktree backing reports uncommitted changes or unpushed commits; otherwise it archives immediately.
- Bespoke status pills. `<StatusBadge>` is the pill primitive.
- Raw `Modal` for a focused task. `<AdaptiveModalSheet>` is the modal primitive.
- Importing `ActivityIndicator` directly. `<LoadingSpinner>` is the loading primitive.

View File

@@ -8,18 +8,28 @@
## Running the dev server
```bash
npm run dev
npm run dev:server
npm run dev:app
npm run dev:desktop
```
`scripts/dev.sh` runs the daemon and Expo together via `concurrently`, fronted by [`portless`](https://www.npmjs.com/package/portless) so each service is reachable at a stable name like `https://daemon.localhost` / `https://app.localhost` instead of a fixed port. The underlying TCP ports are ephemeral — never hardcode them. (Windows uses `scripts/dev.ps1`, which still binds the daemon to `localhost:6767` directly.)
Root checkout dev is intentionally split across terminals:
- `npm run dev:server` runs the daemon on `127.0.0.1:6768`.
- `npm run dev:app` runs Expo on `http://localhost:8081` and connects to the dev daemon.
- `npm run dev:desktop` runs its own Electron-flavored Expo server on the first free port from `8082` through `8089`. It never claims port `8081`.
`npm run dev` is only a shorthand for `npm run dev:server`. Keep `127.0.0.1:6767` for the packaged app and production-style `~/.paseo` state.
### PASEO_HOME
`PASEO_HOME` is the directory that holds runtime state (agents, sockets, daemon log). Resolution rules:
`PASEO_HOME` is the directory that holds runtime state (agents, worktrees, workspace config, sockets, daemon log). Resolution rules:
- The **server itself** (e.g. when launched by the desktop app or `npm run start`) defaults to `~/.paseo` (see `packages/server/src/server/paseo-home.ts`).
- **`npm run dev` from a git worktree** derives a stable home like `~/.paseo-<worktree-name>` and, on first run, seeds it from `~/.paseo` by copying agent/project JSON metadata and `config.json`. Checkout/worktree directories are not copied.
- **`npm run dev` from the main checkout** (not a worktree) uses a fresh `mktemp` directory under `$TMPDIR` and removes it on exit. Set `PASEO_HOME` explicitly to keep state across runs.
- **Repo dev scripts** default to `$ROOT/.dev/paseo-home`, where `$ROOT` is the current checkout or worktree root. This keeps all dev state scoped to the checkout instead of the packaged desktop app.
- **`npm run cli -- ...`** runs through the same dev-home wrapper as the dev scripts, so the in-repo CLI automatically targets the current checkout's `.dev/paseo-home` and configured dev daemon endpoint.
- **Paseo-created worktrees** seed `$PASEO_WORKTREE_PATH/.dev/paseo-home` from `$PASEO_SOURCE_CHECKOUT_PATH/.dev/paseo-home` by copying durable JSON metadata. Runtime files like pid files, sockets, and logs are not copied.
- **This repo's worktree setup** also best-effort seeds `packages/app/ios` and the newest `.dev/ios-build` entry from the source checkout so iOS simulator services can reuse native project and Xcode cache state when it is safe enough to do so.
Override knobs:
@@ -32,16 +42,145 @@ PASEO_DEV_RESET_HOME=1 npm run dev # clear and reseed the derived wor
### Daemon endpoints
- Stable daemon launched by the desktop app: `localhost:6767`.
- `npm run dev` (macOS/Linux): portless URLs only — read them from the `dev.sh` banner or `portless get daemon` / `portless get app`.
- Root checkout dev daemon: `localhost:6768`.
- Root checkout Expo: `http://localhost:8081`.
- Root checkout desktop dev Expo: first free port from `8082` through `8089`.
- `npm run dev` (Windows): `localhost:6767` for the daemon.
In any worktree-style or portless setup, never assume default ports.
In Paseo-managed worktree services, use the injected service environment rather than hardcoded root checkout ports.
### Expo Router
Route ownership, startup restore, and native blank-screen gotchas live in
[expo-router.md](expo-router.md). Read it before changing `packages/app/src/app`,
startup routing, remembered workspace restore, or active workspace selection.
### iOS simulator preview service
Paseo worktrees expose the native iOS dev app through the `ios-simulator` service in `paseo.json`. The service URL serves the simulator preview at `/.sim`, so the preview link is `${PASEO_URL}/.sim`.
**Prerequisites (macOS only).** The service shells out to the Apple toolchain, so beyond the `npm ci` that worktree setup runs you must install:
- **Xcode** (the full app, not just the Command Line Tools) — install it from the Mac App Store, or from `developer.apple.com/download` for a specific version. It provides `xcodebuild` and `xcrun simctl`; accept its license and let first-run component installation finish before starting the service.
- **An iOS Simulator runtime with at least one iPhone device type**. Recent Xcode versions may not bundle a runtime — add one via Xcode → Settings → Components (older Xcode: "Platforms"). The service targets `iPhone 16 Pro` by default (override with `PASEO_IOS_DEVICE_TYPE`) and falls back to any iPhone; it fails with `No iPhone simulator device type is installed` when none exist.
- **Homebrew** — CocoaPods itself installs automatically: `expo prebuild` runs `pod install` on a cold worktree, and when the CocoaPods CLI is missing the runner installs it for you. It tries `gem install cocoapods` first and falls back to Homebrew (`brew install cocoapods`), so having Homebrew available lets that fallback succeed without a manual step.
`serve-sim`, Expo, and Metro come from `npm ci`, and CocoaPods installs itself on the first prebuild as described above.
The service is designed for concurrent worktrees: it derives a deterministic simulator identity from the worktree path, uses the worktree's assigned `PASEO_PORT`, pins `serve-sim` to that simulator UDID, and only tears down that worktree's helper/simulator state. It must not rely on the globally booted simulator or any fixed Metro port.
Worktree setup best-effort seeds the generated iOS project and newest native build cache from the source checkout before the service runs. The service still validates the native project by running Expo prebuild and Xcode; the seed only avoids paying all setup/build cost from a cold worktree every time.
Starting the service must not create, focus, reveal, or leave behind macOS Simulator.app windows — a guard hides Simulator.app every 250ms, so the native window vanishes if you focus it. The user-visible surface is the interactive `/.sim` preview: a `serve-sim` stream (60 FPS MJPEG + a WebSocket control channel) that Metro mounts at `basePath: "/.sim"` (`packages/app/metro.config.cjs`) and that forwards taps and gestures, so first-launch prompts like "Open in PaseoDebug?" are answered there, not in the native window. Open the `${PASEO_URL}/.sim` link the service prints — not `serve-sim`'s raw stream port (`:3100`), which is view-only. Because the stream sits behind the daemon proxy it is convenient for remote viewing but laggy up close; for fast local dev at the Mac, use the native simulator path below.
**Troubleshooting.** If `xcrun simctl` fails with `unable to find utility "simctl"`, the active developer directory is still the Command Line Tools even though Xcode is installed. Point it at Xcode: `sudo xcode-select -s /Applications/Xcode.app/Contents/Developer`, then confirm with `xcrun --find simctl`.
### Running the iOS app on a local simulator
For fast, native, interactive iOS dev at the Mac — as opposed to the remote `/.sim` preview above — skip the service and build the dev client directly:
```bash
npm run ios # → expo run:ios (packages/app): builds and launches the app in the real Simulator.app
```
`expo run:ios` starts its own Metro and gives you the normal Simulator.app window (full speed, native touch, no stream).
**Pointing the app at a daemon.** The client resolves its local daemon from `EXPO_PUBLIC_LOCAL_DAEMON` (`packages/app/src/runtime/host-runtime.ts`); when unset it falls back to `localhost:6767`, the production `~/.paseo` daemon. To target a worktree's dev daemon instead, set it on the build command:
```bash
EXPO_PUBLIC_LOCAL_DAEMON=localhost:${PASEO_SERVICE_DAEMON_PORT} npm run ios # worktree daemon running as a Paseo service
EXPO_PUBLIC_LOCAL_DAEMON=localhost:6768 npm run ios # standalone `npm run dev:server`
```
The iOS simulator shares the Mac's loopback, so `localhost:<port>` reaches the host daemon directly.
**Gotcha — `EXPO_PUBLIC_*` is inlined into the JS bundle at Metro bundle time, not read at runtime.** Set it in the same shell that starts Metro. If the app still connects to the old daemon, Metro served a cached bundle; re-bundle clean with `cd packages/app && EXPO_PUBLIC_LOCAL_DAEMON=… npx expo start -c` and reload the app.
### Desktop renderer profiling
`npm run dev:desktop` starts Electron with Chromium remote debugging enabled on
`http://127.0.0.1:9223` so renderer CPU profiles can be captured through CDP.
Override the port with `PASEO_ELECTRON_REMOTE_DEBUGGING_PORT` when `9223` is busy.
It launches its own Electron-flavored Expo server and passes that URL to Electron.
Override the CDP port with `PASEO_ELECTRON_REMOTE_DEBUGGING_PORT` when `9223` is busy.
With desktop dev running, verify the real BrowserWindow, titlebar clearance, fullscreen
transition, and 751-pixel settings split with:
```bash
npm run verify:electron-cdp --workspace=@getpaseo/desktop
```
The verifier reads the same `EXPO_PORT` and
`PASEO_ELECTRON_REMOTE_DEBUGGING_PORT` environment names as desktop dev. Set both when
testing an isolated instance on non-default ports.
When running a dedicated Electron QA instance against a non-default Expo port, set
`EXPO_DEV_URL` explicitly. Desktop main defaults to `http://localhost:8081`, so
`PASEO_PORT=57928` alone starts Metro on 57928 but Electron still loads 8081.
### React render profiling
The app has a gated React render profiler in
`packages/app/src/utils/render-profiler.tsx`. Wrap the component boundary you want
to measure with `RenderProfile`, then open the app with `?renderProfile=1`. When
the query param is absent, `RenderProfile` returns children directly and records
nothing.
Captured samples are exposed on `globalThis.__PASEO_RENDER_PROFILE__`. Call
`globalThis.__PASEO_RESET_RENDER_PROFILE__?.()` after warm-up and before the
interaction you want to measure. If a memo comparator or subscription boundary
needs explanation, call `recordRenderProfileReasons(id, reasons)` while profiling;
reason counts are exposed on `globalThis.__PASEO_RENDER_PROFILE_REASONS__`.
Use this workflow for any render investigation:
1. Add stable `RenderProfile` boundaries around the suspected root and expensive
children. Keep IDs specific enough to compare before and after.
2. Reproduce against real app state, not toy fixtures, whenever practical.
3. Record an idle baseline first. If idle is noisy, fix or account for that
before optimizing the interaction.
4. Warm up the route, reset profiler samples, run the exact interaction, then
compare `actualDuration`, render counts, and per-commit samples.
5. When a memo boundary still renders, record reasons before changing code. Do
not guess from object identity alone.
6. Keep changes that move the measured profile. Remove probes or memo wrappers
that do not move the number.
What this caught during the workspace tab investigation:
- A large apparent workspace cost was real interaction work, not daemon noise;
the idle baseline stayed near zero.
- The expensive stream rerender was mostly prop identity churn from pane context
callbacks and capability objects, not new stream data.
- Stabilizing provider actions at the pane boundary helped because every mounted
panel consumes that context.
- Comparing value-shaped capability flags beat preserving object identity through
unrelated stores.
- Some plausible fixes did not pay off: memoizing the tab row and composer draft
object barely moved the profile, so they were removed.
Existing scenario script: workspace agent/terminal tab switching. Start Expo on
web, keep a daemon available, then run:
```bash
PASEO_PROFILE_SERVER_ID=<server-id> \
PASEO_PROFILE_WORKSPACE_ID=<workspace-path> \
PASEO_PROFILE_AGENT_ID=<agent-id> \
npm run profile:workspace-tabs --workspace=@getpaseo/app
```
This script opens the app with `?renderProfile=1`, creates a temporary terminal
tab, switches between a real agent and that terminal, prints aggregated React
Profiler timings, then removes the temporary terminal. It is an example of the
workflow above, not the only way to use the profiler. Useful knobs:
```bash
PASEO_PROFILE_APP_URL=http://localhost:19010 # Expo web URL
PASEO_PROFILE_SWITCH_COUNT=1 # number of agent/terminal switch pairs
PASEO_PROFILE_SWITCH_WAIT_MS=250 # delay after each click
PASEO_PROFILE_IDLE_WAIT_MS=3000 # idle baseline before switching
PASEO_PROFILE_DUMP_COMMITS=1 # include per-commit profiler samples
```
### Desktop macOS compositor watchdog
@@ -59,6 +198,12 @@ GPU process so Chromium rebuilds the display link. The probe is skipped while
the screen is locked or the window is hidden or minimized, since a window
legitimately stops producing frames then.
The watchdog deliberately leaves background throttling **enabled**. Calling
`webContents.setBackgroundThrottling(false)` would keep the compositor producing
frames non-stop, pinning ProMotion displays at 120Hz forever and draining the
battery while the app is idle — so do not re-add it. The probe's visibility
guards already prevent throttling from causing a false stall.
### Daemon logs
Check `$PASEO_HOME/daemon.log` for daemon logs. The default level is `info`; set
@@ -70,11 +215,33 @@ The supervisor rotates `daemon.log`. Persisted `log.file.rotate` settings in
`PASEO_LOG_ROTATE_SIZE` and `PASEO_LOG_ROTATE_COUNT` env vars override the
defaults. The default rotation is `10m` x `3` files everywhere.
### Agent Tool Catalog Measurement
Measure the MCP `tools/list` payload that Paseo injects into agents with:
```bash
npm run measure:agent-tools --workspace=@getpaseo/server
```
The command reports compact JSON bytes, estimated tokens, field totals, largest
tools, and the browser-tools delta. It defaults to the agent-scoped catalog; use
`-- --scope=top-level` for the unaffiliated `/mcp/agents` shape and `-- --json`
for machine-readable output.
## paseo.json service scripts
`worktree.setup` and `worktree.teardown` accept either a multiline shell script or an array
of commands. Both run sequentially.
Lifecycle commands run in the worktree through a stable script shell: `bash`
resolved from `PATH` on macOS/Linux, and PowerShell with `-NoProfile` on
Windows. They inherit the daemon environment plus Paseo's lifecycle variables;
login and interactive shell startup files are not loaded, and Bash's `BASH_ENV`
hook is unset. Daemon-run loop verify checks and ACP single-string terminal
commands use the same non-login Bash behavior on macOS/Linux, but preserve their
existing `cmd.exe /c` string semantics on Windows. Service scripts are separate:
they launch in a terminal and receive the service environment described below.
```json
{
"worktree": {
@@ -88,12 +255,14 @@ Every `scripts` entry with `"type": "service"` receives these environment variab
| Variable | Value |
| --------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| `PASEO_SERVICE_<NAME>_URL` | Proxied daemon URL for a declared peer service. Prefer this for peer discovery; it survives peer restarts. |
| `PASEO_SERVICE_<NAME>_URL` | Proxied URL for a declared peer service. Prefer this for peer discovery; it survives peer restarts. |
| `PASEO_SERVICE_<NAME>_PORT` | Raw ephemeral port for a declared peer service. Use only as a bypass escape hatch; it can go stale if that peer restarts. |
| `PASEO_URL` | Self alias for `PASEO_SERVICE_<SELF>_URL`. |
| `PASEO_PORT` | Self alias for `PASEO_SERVICE_<SELF>_PORT`. |
| `HOST` | Bind host for the service process. |
Service proxy hostnames use the double-dash shape: `web--feature-auth--project.localhost` or, on the default branch, `web--project.localhost`. Optional public aliases use the same leftmost label under the configured public base host.
`<NAME>` is normalized from the script name by uppercasing it, replacing each run of non-`A-Z0-9` characters with `_`, and trimming leading or trailing `_`. For example, `app-server` and `app.server` both normalize to `APP_SERVER`; that collision fails at spawn time with an actionable error.
`PORT` is not injected by default. If a framework requires `PORT`, set it in the command:
@@ -109,11 +278,61 @@ Every `scripts` entry with `"type": "service"` receives these environment variab
}
```
## Bundled daemon web UI
> The user-facing guide for this feature (enabling it, reverse proxy, TLS, tunnels, security) lives at [public-docs/web-ui.md](../public-docs/web-ui.md). This section is the contributor/build reference: how the artifact is produced, bundled, and excluded from desktop packaging.
The daemon can optionally serve the browser web client from the same HTTP server. This is disabled by default.
Enable it for a running daemon with:
```bash
paseo daemon start --web-ui
```
Or set the environment variable:
```bash
PASEO_WEB_UI_ENABLED=true paseo daemon start
```
Or persist it in `config.json`:
```json
{
"features": {
"webUi": {
"enabled": true
}
}
}
```
When enabled, opening the daemon HTTP origin (for example `http://localhost:6767/`) serves the web app. The same HTTP server continues to serve `/api/*`, `/mcp/*`, `/public/*`, the WebSocket upgrade, and service-proxy routes. Static files load without daemon bearer auth; API and WebSocket calls still enforce auth.
The served app auto-bootstraps a connection to the same origin, so opening `http://localhost:6767/` directly usually skips the Add Host step.
Build the artifact for packaging or measurement with:
```bash
npm run build:daemon-web-ui
```
This exports the normal browser web app (not the Electron-flavored desktop renderer) and copies it into `packages/server/dist/server/web-ui`, precompressing `.html`, `.js`, `.css`, and JSON assets as `.br` and `.gz`.
Measured bundle size for a standard Expo web export:
- raw: 10.77 MiB
- gzip: 2.55 MiB
- brotli: 1.93 MiB
The desktop-managed daemon disables the bundled web UI by default (`PASEO_WEB_UI_ENABLED=false`) because the desktop app already ships the renderer as `app-dist`. Shipping the same assets again inside `@getpaseo/server` would duplicate the ~10.8 MiB install. Desktop packaging also excludes `node_modules/@getpaseo/server/dist/server/web-ui/**` from the packaged app.
## Built workspace packages
Package imports resolve through package exports to compiled `dist/` output, not sibling `src/` files. This is true in local dev and in published packages: the app, daemon, CLI, and SDK consumers should all exercise the same runtime paths.
`npm run dev`, `npm run dev:server`, and `npm run dev:app` build the workspace packages they need once, then keep `@getpaseo/protocol` and `@getpaseo/client` fresh with TypeScript watch builds while the daemon or Expo runs. If you change protocol schemas or client code outside those watch workflows, rebuild the producer before trusting runtime behavior.
`npm run dev:server` builds the server-side workspace packages once, then keeps `@getpaseo/protocol` and `@getpaseo/client` fresh with TypeScript watch builds while the daemon runs. If you change protocol schemas or client code outside that watch workflow, rebuild the producer before trusting runtime behavior.
Use the named root build targets instead of remembering workspace dependency chains:
@@ -126,15 +345,36 @@ npm run build:app-deps # highlight -> protocol -> client -> expo-two-way-aud
Use `npm run build:server` whenever you have changed any daemon/server-facing package and need clean cross-package types or runtime behavior.
The app Metro config disables Watchman and uses Metro's node crawler for exports. Keep that invariant unless you have verified production app exports on machines with and without Watchman installed; distro Watchman builds can differ in capabilities and change Metro's crawl behavior.
For tighter loops, you can rebuild a single workspace:
- Changed `packages/protocol/src/*` or `packages/client/src/*`: `npm run build:client`.
- Changed `packages/server/src/*`, `packages/cli/src/*`, `packages/relay/src/*`, or `packages/highlight/src/*`: `npm run build:server`.
- Changed app build dependencies: `npm run build:app-deps`.
## ACP provider catalog versions
The in-app ACP provider catalog pins package-runner entries (`npx`, `npm exec`,
and `uvx`) to exact package versions. Run the drift checker regularly — and
before releases — so catalog installs do not sit on stale agent versions:
```bash
npm run acp:version-drift # report stale/non-exact package pins
npm run acp:version-drift:check # same, exits non-zero on drift
npm run acp:version-drift:update # rewrite catalog pins to latest exact versions
```
The checker updates only package-runner catalog entries. Providers that use a
preinstalled binary such as `opencode acp`, `cursor-agent acp`, or `goose acp`
are reported as skipped because their versions are owned by the user's local
install.
## CLI reference
Use `npm run cli` to run the in-repo CLI from source (`npx tsx packages/cli/src/index.ts`). 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.
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 --isolation 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
@@ -142,6 +382,7 @@ npm run cli -- ls -a -g --json # Same, as JSON
npm run cli -- inspect <id> # Show detailed agent info
npm run cli -- logs <id> # View agent timeline
npm run cli -- daemon status # Check daemon status
npm run cli -- clone owner/repo --dir ~/workspace # Clone GitHub repo and register project
```
Use `--host <host:port>` to point the CLI at a different daemon:
@@ -188,7 +429,7 @@ Get the session ID from the agent JSON (`persistence.sessionId`), then:
## Testing with Playwright MCP
Point Playwright MCP at the running Expo web target. Under `npm run dev` (macOS/Linux) that is the portless URL printed in the dev banner — typically `https://app.localhost`. If you start Expo directly with `expo start --web` (no portless), Metro defaults to `http://localhost:8081`.
Point Playwright MCP at the running Expo web target. For root checkout dev, `npm run dev:app` reserves `http://localhost:8081`. For Paseo-managed worktree app services, use the service URL or port shown by Paseo for that worktree.
Do NOT use browser history (back/forward). Always navigate by clicking UI elements or using `browser_navigate` with the full URL — the app uses client-side routing and browser history breaks state.

View File

@@ -1,211 +0,0 @@
# Git Snapshot Startup Reshaping - 2026-05-27
## What changed
The sidebar PR badge no longer has a special per-row fetch path. It is derived from the workspace snapshot, the same way the sidebar already gets branch/diff metadata.
```text
daemon startup / workspace subscription
-> WorkspaceGitService.refreshSnapshot(cwd)
-> getCheckoutSnapshotFacts(cwd)
-> getCheckoutStatus(cwd, { facts })
-> getCheckoutShortstat(cwd, { facts })
-> getPullRequestStatus(cwd, github, ..., { facts })
-> WorkspaceGitRuntimeSnapshot
-> session workspace descriptor githubRuntime.pullRequest
-> app useSidebarWorkspacesList()
-> SidebarWorkspaceEntry.prHint
-> Sidebar row badge + hover card checks
```
The remaining `checkout_pr_status_request` path is still present for explicit PR surfaces and compatibility, but the sidebar row badge no longer calls `useWorkspacePrHint()` and therefore no longer generates ad hoc checkout PR status requests per visible row.
## Shared Git Facts
`getCheckoutSnapshotFacts()` is now the first git read in the workspace snapshot builder. It gathers facts that were previously rediscovered by separate functions:
- worktree root: `rev-parse --show-toplevel`
- current branch: `rev-parse --abbrev-ref HEAD`
- origin remote URL
- Paseo worktree ownership and stored base ref
- resolved base ref and best comparison base
- main repo root
- branch remote/merge config
- tracked origin branch
- pull request lookup target for fork/PR worktrees
Those facts are then passed through `CheckoutContext` so status, shortstat, and PR status reuse the same answers instead of independently re-reading them.
## Current Data Flow
```text
Workspace subscription / fetch_workspaces
-> session workspace registry
-> workspaceGitService.getSnapshot(cwd, includeGitHub)
-> refresh queue/throttle/dedupe per normalized cwd
-> refreshGitSnapshot()
-> getCheckoutSnapshotFacts()
-> getCheckoutStatus({ facts })
-> getCheckoutShortstat({ facts })
-> refreshGitHubSnapshot()
-> getPullRequestStatus({ facts })
-> cached WorkspaceGitRuntimeSnapshot
-> WorkspaceDescriptorPayload.gitRuntime
-> WorkspaceDescriptorPayload.githubRuntime
-> app session store
-> useSidebarWorkspacesList()
-> diffStat from descriptor
-> prHint from descriptor.githubRuntime.pullRequest
```
## Startup Benchmark
Added deterministic real-home benchmark:
`packages/server/scripts/benchmark-startup-git-real-home.ts`
The script freezes the current Paseo home using the same metadata-copy shape as `scripts/dev-home.sh`: JSON under `agents`, JSON under `projects`, and `config.json`. It then starts an isolated in-process daemon against that frozen home, subscribes to workspaces/agents, records git invocations through `runGitCommand`, and reports elapsed time, git count, max concurrency, CPU, and memory deltas.
The frozen home used for the comparison contained 22 workspaces.
### Before/After
| run | code shape | client shape | git commands | failures | elapsed |
| ----------- | -------------------------- | ----------------------------------------- | -----------: | -------: | ------: |
| baseline | before change | legacy sidebar PR fanout | 529 | 20 | 39039ms |
| split check | after change | legacy sidebar PR fanout | 375 | 15 | 39039ms |
| after | after change | snapshot-only sidebar, no PR badge fanout | 372 | 15 | 31273ms |
| after 2 | after service fact sharing | snapshot-only sidebar, no PR badge fanout | 308 | 15 | 31334ms |
The server-side fact reuse accounts for nearly all measured git command reduction: `529 -> 375` (`-154`, `-29.1%`) even when the old PR fanout is still forced. Removing the sidebar fanout removes the ad hoc request path, but in this run it only changed command count by `3` because the refreshed workspace snapshots already carried the PR data by the time the fanout ran.
The second pass shares checkout facts between workspace observation setup and snapshot refresh. That removes another `64` git commands from the same frozen-home run: `372 -> 308` (`-17.2%` from the previous after, `-41.8%` from baseline).
### Baseline: before change + legacy PR fanout
```json
{
"scenario": "legacyPrFanout",
"workspaceCount": 22,
"elapsedMs": 39039,
"git": {
"total": 529,
"failed": 20,
"maxConcurrent": 8,
"byCommand": [
{ "key": "show-ref --verify --quiet refs/heads/main", "count": 66 },
{ "key": "rev-parse --git-common-dir", "count": 58 },
{ "key": "rev-parse --abbrev-ref HEAD", "count": 50 },
{ "key": "rev-parse --git-dir", "count": 36 },
{ "key": "show-ref --verify --quiet refs/remotes/origin/main", "count": 35 },
{ "key": "symbolic-ref --quiet refs/remotes/origin/HEAD", "count": 35 },
{ "key": "config --get remote.origin.url", "count": 32 },
{ "key": "ls-files --others --exclude-standard", "count": 18 },
{ "key": "rev-parse --absolute-git-dir", "count": 18 },
{ "key": "merge-base HEAD origin/main", "count": 17 },
{ "key": "rev-parse --show-toplevel", "count": 14 },
{ "key": "status --porcelain", "count": 14 }
]
},
"process": {
"cpuUserMs": 2009,
"cpuSystemMs": 2428,
"rssDeltaMb": -1.5,
"heapUsedDeltaMb": 16.9
}
}
```
### After: after change + snapshot-only sidebar
```json
{
"scenario": "snapshotOnly",
"workspaceCount": 22,
"elapsedMs": 31273,
"git": {
"total": 372,
"failed": 15,
"maxConcurrent": 8,
"byCommand": [
{ "key": "config --get remote.origin.url", "count": 35 },
{ "key": "show-ref --verify --quiet refs/heads/main", "count": 34 },
{ "key": "rev-parse --git-common-dir", "count": 31 },
{ "key": "show-ref --verify --quiet refs/remotes/origin/main", "count": 22 },
{ "key": "status --porcelain", "count": 22 },
{ "key": "ls-files --others --exclude-standard", "count": 18 },
{ "key": "rev-parse --absolute-git-dir", "count": 18 },
{ "key": "merge-base HEAD origin/main", "count": 17 },
{ "key": "rev-parse --abbrev-ref HEAD", "count": 17 },
{ "key": "rev-parse --show-toplevel", "count": 17 },
{ "key": "symbolic-ref --quiet refs/remotes/origin/HEAD", "count": 17 },
{ "key": "rev-list --count main..origin/main", "count": 7 }
]
},
"process": {
"cpuUserMs": 1871,
"cpuSystemMs": 2152,
"rssDeltaMb": 4.4,
"heapUsedDeltaMb": 8.8
}
}
```
### After 2: shared service-level facts
```json
{
"scenario": "snapshotOnly",
"workspaceCount": 22,
"elapsedMs": 31334,
"git": {
"total": 308,
"failed": 15,
"maxConcurrent": 8,
"byCommand": [
{ "key": "show-ref --verify --quiet refs/heads/main", "count": 31 },
{ "key": "rev-parse --git-common-dir", "count": 26 },
{ "key": "show-ref --verify --quiet refs/remotes/origin/main", "count": 22 },
{ "key": "ls-files --others --exclude-standard", "count": 18 },
{ "key": "status --porcelain", "count": 18 },
{ "key": "merge-base HEAD origin/main", "count": 17 },
{ "key": "config --get remote.origin.url", "count": 13 },
{ "key": "rev-parse --abbrev-ref HEAD", "count": 13 },
{ "key": "rev-parse --absolute-git-dir", "count": 13 },
{ "key": "rev-parse --show-toplevel", "count": 13 },
{ "key": "symbolic-ref --quiet refs/remotes/origin/HEAD", "count": 13 },
{ "key": "fetch origin --prune", "count": 5 }
]
},
"process": {
"cpuUserMs": 1817,
"cpuSystemMs": 1869,
"rssDeltaMb": 16.7,
"heapUsedDeltaMb": 10.4
}
}
```
## Snapshot Equivalence Guard
Added a focused utility test proving that status, shortstat, and PR status return the same data when run from shared snapshot facts. The same test records git calls and asserts the facts-backed path does not re-run:
- `rev-parse --show-toplevel`
- `rev-parse --abbrev-ref HEAD`
Test:
`packages/server/src/utils/checkout-git.test.ts` -> `reuses checkout snapshot facts across status, shortstat, and PR status reads`
## Remaining Waste Visible In Baseline
This pass reshaped the data flow and removed the sidebar PR badge special path. It did not try to optimize every command.
The benchmark still shows repeated per-workspace reads that are candidates for the next pass:
- base ref existence checks still repeat as `show-ref` probes.
- default branch resolution still repeats `symbolic-ref refs/remotes/origin/HEAD`.
- repo common-dir lookup is lower, but still above the apparent git workspace count.
- shortstat still runs its own merge-base/diff/untracked scan per workspace.
The important invariant now is clearer: sidebar-visible git data should flow from `WorkspaceGitService` snapshots, and snapshot builders should receive reusable git facts through `CheckoutContext`.

View File

@@ -1,389 +0,0 @@
# OpenCode Provider Snapshot Startup Timeout Diagnosis - 2026-05-27
## Answer
The startup timeout is real OpenCode provider snapshot work, not an agent resume path.
In the dev-style copied-home reproduction, the OpenCode snapshot misses the 30s budget because several expensive things stack:
1. Paseo starts from a copied `PASEO_HOME` containing 4,851 agent records.
2. Clients ask for provider snapshots for three cwd scopes at almost the same time:
- `/Users/moboudra`
- `/Users/moboudra/dev/paseo`
- `/Users/moboudra/dev/blankpage/editor`
3. Each OpenCode snapshot runs two OpenCode SDK calls:
- `GET /provider?directory=...` through `client.provider.list()`
- `GET /agent?directory=...` through `client.app.agents()`
4. One cold `opencode serve` process is shared by the three cwd scopes. It took 8.562s to become ready.
5. After OpenCode was listening, Paseo issued six OpenCode HTTP calls concurrently.
6. The OpenCode `/provider` responses are large: about 3,549,620 decompressed bytes per cwd.
7. During the same window, the daemon was still doing heavy startup workspace git work. In the exact 18:14:19-18:14:43 window, the daemon log has 292 git spawn/close events.
8. The `/provider` calls eventually succeeded, but too late: they completed about 32.2s-32.5s after the snapshot fetch started, while the snapshot timeout is 30s.
So the root cause is:
```text
Cold OpenCode server startup + three concurrent cwd snapshots + large OpenCode /provider responses + daemon startup git contention causes client.provider.list() to complete after Paseo's 30s snapshot budget.
```
More precise wording: the contention is machine-level process/CPU/filesystem contention created by daemon startup work, especially git work. It is not proven to be an OpenCode internal lock or a Paseo-only event-loop issue. A daemon-free repro with only OpenCode plus an external git storm slowed the same six OpenCode calls from about 1s to about 30s total.
Manual settings refresh works because it runs after startup contention is gone and uses `force: true`, which creates fresh OpenCode runtime/server state. The same OpenCode provider refreshes then complete in about 1.7s-2.2s.
The daemon does not auto-retry error snapshots. A failed provider snapshot is cached as `status: "error"` until an explicit refresh resets it to loading.
## Follow-up: Normal Copied-Home Startup Check
I later reran a normal dev-daemon startup against a fresh copy of the same Paseo home metadata and drove the app startup request path:
```text
fetchWorkspaces
fetchAgents
getProvidersSnapshot(home scope)
getProvidersSnapshot(first workspace scope)
```
That run did not reproduce the 30s OpenCode timeout.
```text
home scope:
OpenCode ready at ~8s
availability: 1.6s
fetch total: 5.2s
first workspace scope:
OpenCode ready at ~26s
availability: 2.0s
fetch total: 15.4s
```
The slowest OpenCode operation in that successful run was the workspace-scoped `/provider` response body read: `13.6s`. The daemon log had no `Timed out refreshing OpenCode` entry and no OpenCode provider snapshot failure.
This means the timeout is reproducible under the heavier multi-scope startup contention captured below, but it is not guaranteed on every copied-home dev startup.
## Reproduction Used
The user's correction was right: the useful reproduction is not a random isolated home. It must match `dev.sh` worktree behavior.
Relevant scripts:
- `scripts/dev.sh`
- `scripts/dev-daemon.sh`
- `scripts/dev-home.sh`
`dev-home.sh` only seeds this metadata into the dev home:
```text
agents/**/*.json
projects/**/*.json
config.json
```
It does not copy `chat`, `loops`, `schedules`, sockets, pid files, logs, or worktree contents.
I ran a separate daemon, not the main daemon:
```text
PASEO_HOME=/var/folders/xl/kkk9drfd3ms_t8x7rmy4z6900000gn/T/paseo-devseed.Wms6pi
PASEO_LISTEN=127.0.0.1:51116
PASEO_LOG_LEVEL=trace
```
Startup facts:
```text
18:13:39.552 Agent storage initialized: 712ms
18:13:39.559 Workspace registries bootstrapped: 719ms
18:13:39.961 Agent registry loaded: 4851 records
18:13:39.972 Server listening: http://127.0.0.1:51116
```
The probe then connected four client sessions and requested:
- workspaces
- active agents
- provider snapshots for home, paseo, and blankpage/editor
Client-visible result:
```text
18:14:30.263 /Users/moboudra/dev/blankpage/editor opencode error:
OpenCode app.agents timed out after 10s
18:14:41.687 /Users/moboudra/dev/paseo opencode error:
Timed out refreshing OpenCode after 30000ms
18:14:41.688 /Users/moboudra opencode error:
Timed out refreshing OpenCode after 30000ms
```
## Exact OpenCode Timeline
OpenCode snapshot requests began at `18:14:10`.
Availability checks:
```text
18:14:10.780 opencode availability start for /Users/moboudra
18:14:10.787 opencode availability start for /Users/moboudra/dev/paseo
18:14:10.800 opencode availability start for /Users/moboudra/dev/blankpage/editor
18:14:11.363 paseo availability complete: 576ms
18:14:11.376 home availability complete: 597ms
18:14:11.391 blankpage availability complete: 591ms
```
OpenCode server acquisition:
```text
18:14:11.364 OpenCode server spawn start: opencode serve --port 56376
18:14:19.926 OpenCode server listening after 8562ms
```
Six SDK calls were then issued:
```text
18:14:19.931 GET /provider directory=/Users/moboudra/dev/paseo
18:14:19.931 GET /agent directory=/Users/moboudra/dev/paseo
18:14:19.931 GET /provider directory=/Users/moboudra
18:14:19.931 GET /agent directory=/Users/moboudra
18:14:19.931 GET /provider directory=/Users/moboudra/dev/blankpage/editor
18:14:19.936 GET /agent directory=/Users/moboudra/dev/blankpage/editor
```
Why six:
| Cwd | Why that scope exists | Model call | Mode call |
| -------------------------------------- | ---------------------------------------------------------- | --------------------------------------- | --------------------------------- |
| `/Users/moboudra` | home/settings provider snapshot | `client.provider.list()` -> `/provider` | `client.app.agents()` -> `/agent` |
| `/Users/moboudra/dev/paseo` | workspace-scoped provider snapshot for the Paseo workspace | `client.provider.list()` -> `/provider` | `client.app.agents()` -> `/agent` |
| `/Users/moboudra/dev/blankpage/editor` | workspace/agent cwd snapshot for blankpage/editor | `client.provider.list()` -> `/provider` | `client.app.agents()` -> `/agent` |
Multiple clients can request the same snapshot scope during startup, but non-forced provider loads are deduped by `(cwd, provider)`. Different cwd scopes are separate loads. Three cwd scopes times two OpenCode SDK calls each is the six OpenCode calls in this repro.
Headers arrived before the 30s timeout:
| Call | Cwd | Headers after request |
| ----------- | ------------------------------- | --------------------- |
| `/provider` | `/Users/moboudra` | 6.462s |
| `/agent` | `/Users/moboudra` | 6.681s |
| `/agent` | `/Users/moboudra/dev/paseo` | 6.681s |
| `/provider` | `/Users/moboudra/dev/paseo` | 8.192s |
| `/provider` | `/Users/moboudra/dev/blankpage` | 8.654s |
| `/agent` | `/Users/moboudra/dev/blankpage` | 8.649s |
But body consumption and completion lagged:
```text
18:14:29.380 /agent home complete, total app.agents duration 9450ms
18:14:29.813 /agent paseo complete, total app.agents duration 9883ms
18:14:30.263 /agent blankpage timed out at 10s
18:14:31.332 /agent blankpage body finally finished, after the 10s app.agents timeout
18:14:41.687 paseo snapshot outer 30s timeout fires
18:14:41.688 home snapshot outer 30s timeout fires
18:14:43.593 /provider home completes, provider.list duration 23664ms, total listModels 32218ms
18:14:43.798 /provider blankpage completes, provider.list duration 23868ms, total listModels 32411ms
18:14:43.839 /provider paseo completes, provider.list duration 23911ms, total listModels 32476ms
```
The useful `/provider` results arrived about 1.9s-2.2s after the snapshot manager had already marked home and paseo as failed.
## Why Settings Refresh Works
After the daemon settled, I ran the same refresh path through the daemon on port `51116`, using `refreshProvidersSnapshot({ providers: ["opencode"] })`.
Results:
```text
home refresh:
total: 2165ms
status: ready
models: 409
modes: 5
/Users/moboudra/dev/paseo refresh:
total: 1675ms
status: ready
models: 409
modes: 5
/Users/moboudra/dev/blankpage/editor refresh:
total: 1794ms
status: ready
models: 409
modes: 5
```
Trace details for the manual-style refresh:
```text
OpenCode server acquisition: 708ms-1291ms
/agent completion: 433ms-592ms after request start
/provider completion: 524ms-618ms after request start
```
That proves the startup failure is not bad credentials, not a permanently wedged OpenCode install, and not OpenCode generally taking more than 30s. It is startup timing and contention.
## Minimal OpenCode-Only Repros
### OpenCode Only, No Daemon, No Artificial Load
I started a fresh `opencode serve`, waited for stdout `listening on`, then issued the same six HTTP calls concurrently:
```text
GET /provider?directory=/Users/moboudra
GET /agent?directory=/Users/moboudra
GET /provider?directory=/Users/moboudra/dev/paseo
GET /agent?directory=/Users/moboudra/dev/paseo
GET /provider?directory=/Users/moboudra/dev/blankpage/editor
GET /agent?directory=/Users/moboudra/dev/blankpage/editor
```
Three runs:
| Run | `opencode serve` ready | All six calls complete |
| --- | ---------------------- | ---------------------- |
| 1 | 1376ms | 1295ms |
| 2 | 906ms | 1050ms |
| 3 | 939ms | 898ms |
Slowest individual call in those runs:
```text
/provider /Users/moboudra/dev/paseo: 1270ms total
/agent /Users/moboudra/dev/blankpage/editor: 1251ms total
```
So six concurrent OpenCode calls alone are not the bug.
### OpenCode Only Plus External Git Storm, No Daemon
I then ran the same OpenCode-only six-call test while an external shell spawned repeated git commands across the same real workspaces/worktrees. This did not use the Paseo daemon.
Result:
```text
opencode serve ready: 15479ms
all six OpenCode calls complete: 15176ms after server ready
combined cold-start + calls: about 30655ms
```
Individual calls under the external git storm:
| Call | Cwd | Total |
| ----------- | -------------------------------------- | ------: |
| `/provider` | `/Users/moboudra` | 10684ms |
| `/agent` | `/Users/moboudra` | 10767ms |
| `/provider` | `/Users/moboudra/dev/paseo` | 13220ms |
| `/agent` | `/Users/moboudra/dev/paseo` | 13147ms |
| `/provider` | `/Users/moboudra/dev/blankpage/editor` | 14675ms |
| `/agent` | `/Users/moboudra/dev/blankpage/editor` | 15038ms |
This is the daemon-free minimal evidence that process/filesystem contention can push the same OpenCode cold-start + six-call workload to the same 30s boundary.
## Why It Does Not Retry
`ProviderSnapshotManager.getSnapshot()` only starts background warmup for:
- no existing snapshot
- missing providers
- entries still in `loading` with no active load
When refresh fails, `refreshProvider()` stores:
```text
status: "error"
error: "Timed out refreshing OpenCode after 30000ms"
```
An `error` entry is not treated as stale/loading by `getSnapshot()`, so normal reads keep returning the cached error.
Settings refresh calls `refresh_providers_snapshot_request`, which routes to:
```text
refreshSettingsSnapshot()
clearCachedProviders()
resetSnapshotToLoading()
refreshProviders(... force: true)
```
That is why you have to force a manual refresh.
## Git Work During The Repro
This is not the final optimization report, but it matters for the timeout because it overlaps exactly with OpenCode response handling.
Total git commands in the dev-style copied-home daemon log:
```text
632 spawned
632 closed
```
Top cwd counts:
| Count | Cwd |
| ----: | ------------------------------------------------------------------------------------- |
| 44 | `/Users/moboudra/.paseo/worktrees/1luy0po7/merry-ladybug` |
| 44 | `/Users/moboudra/.paseo/worktrees/1luy0po7/hopeful-eel` |
| 44 | `/Users/moboudra/.paseo/worktrees/1luy0po7/fix-compaction-cancel-loading` |
| 44 | `/Users/moboudra/.paseo/worktrees/1luy0po7/fix-archive-worktree-session-history` |
| 44 | `/Users/moboudra/.paseo/worktrees/0vpo9h4b/breezy-toad` |
| 36 | `/Users/moboudra/.paseo/worktrees/steering-policy-refactor-detached` |
| 36 | `/Users/moboudra/.paseo/worktrees/1luy0po7/integration-session-mcp-command-stack` |
| 36 | `/Users/moboudra/.paseo/worktrees/1luy0po7/fix-provider-diagnostic-binary-resolution` |
| 36 | `/Users/moboudra/.paseo/worktrees/1luy0po7/feat-voice-runtime-on-demand` |
| 36 | `/Users/moboudra/.paseo/worktrees/1luy0po7/feat-find-in-pane` |
| 36 | `/Users/moboudra/.paseo/worktrees/1luy0po7/epic-paseo-client-sdk` |
| 24 | `/Users/moboudra/dev/paseo` |
| 24 | `/Users/moboudra/dev/blankpage/editor` |
| 24 | `/Users/moboudra/dev/faro/main` |
| 24 | `/Users/moboudra/dev/konbert/web` |
| 24 | `/Users/moboudra/dev/paseo-cloud` |
In the exact OpenCode pressure window, `18:14:19` through `18:14:43`, there were:
```text
142 git command spawns
150 git command closes
```
The main repeated command shapes were:
```text
76 git rev-parse --show-toplevel
72 git status --porcelain
72 git show-ref --verify --quiet refs/remotes/origin/main
72 git show-ref --verify --quiet refs/heads/main
16 git config --get branch.main.remote
16 git config --get branch.main.merge
16 git rev-list --count main..origin/main
16 git rev-list --count origin/main..main
```
## Original `log.txt` Alignment
The original startup showed the same home and paseo outer timeout shape:
```text
16:04:22.466 /Users/moboudra/dev/paseo:
Timed out refreshing OpenCode after 30000ms
16:04:22.482 /Users/moboudra:
Timed out refreshing OpenCode after 30000ms
```
The original logs did not include SDK fetch/header/body timing, so they could only show the wrapper-level timeout. The dev-style copied-home reproduction with instrumentation now shows the missing link: the `/provider` calls completed just after the 30s snapshot budget.
## Files Instrumented For Diagnosis
Temporary trace instrumentation was added to:
- `packages/server/src/server/agent/provider-snapshot-manager.ts`
- `packages/server/src/server/agent/providers/opencode-agent.ts`
- `packages/server/src/server/agent/providers/opencode/runtime.ts`
- `packages/server/src/server/agent/providers/opencode/server-manager.ts`
The instrumentation is behavior-neutral and only emits trace logs.

View File

@@ -1,381 +0,0 @@
# Daemon Startup Sequence Analysis - 2026-05-27
Source log: `log.txt` at repository root.
Scope: current sliced startup log, starting at daemon worker startup and ending after workspace registry reconciliation and the first OpenCode heartbeat.
This report is descriptive only. It does not propose optimizations.
## Executive Summary
The daemon becomes ready quickly, then does a heavy post-listen startup pass driven by reconnecting clients and workspace/app hydration.
- Worker start: `16:03:46.678`, line 1.
- Server listening: `16:03:48.285`, line 47, elapsed `602ms`.
- First client hello: `16:03:50.285`, line 66.
- Workspace registries reconciled: `16:04:33.666`, line 1777, elapsed `45983ms`.
The startup shape is therefore:
- Daemon listen readiness: about `0.6s`.
- Client reconnect plus workspace/app/provider hydration: about `45s`.
- No git commands after workspace registry reconciliation in this slice.
## Method
I parsed structured trace lines from `log.txt`, especially:
- `Git command closed`
- `agent.session.inbound`
- `agent.session.outbound`
- `ws_slow_request`
- provider snapshot warnings
- provider resume events
Important limitation: git command logs do not carry a websocket request id, so per-request attribution is inferred from timing and server code paths. Per-workspace git counts, command shapes, durations, and failures are exact for this log.
Relevant code paths checked:
- `packages/server/src/server/session.ts`
- `fetch_workspaces_request` calls `syncWorkspaceGitObservers(payload.entries)`.
- `checkout_status_request` calls `workspaceGitService.getSnapshot(resolvedCwd)`.
- `checkout_pr_status_request` calls `workspaceGitService.getSnapshot(cwd)`.
- `packages/server/src/server/workspace-git-service.ts`
- checkout snapshot/root resolution uses `git rev-parse --show-toplevel`.
- snapshot refresh collects dirty state, upstream/ahead/behind, ref existence, and base divergence.
- `packages/app/src/contexts/session-context.tsx`
- initial workspace hydration calls `client.fetchWorkspaces({ sort: activity_at desc, subscribe, page limit 200 })`.
- `packages/app/src/hooks/use-sidebar-workspaces-list.ts`
- sidebar workspace refresh also calls `client.fetchWorkspaces({ sort: activity_at desc, page limit 200 })`.
## Startup Timeline
| time | line | event |
| -------------- | ---: | ------------------------------------------------------------------ |
| `16:03:46.678` | 1 | `DaemonRunner` starts daemon worker |
| `16:03:47.683` | 4 | worker spawned |
| `16:03:47.684` | 6 | daemon keypair loaded |
| `16:03:48.281` | 44 | bootstrap complete, ready to listen |
| `16:03:48.285` | 47 | server listening on `0.0.0.0:6767` |
| `16:03:50.274` | 60 | first websocket awaiting hello |
| `16:03:50.285` | 66 | first client connected via hello |
| `16:04:22.466` | 987 | OpenCode provider snapshot timeout for `/Users/moboudra/dev/paseo` |
| `16:04:22.482` | 1002 | OpenCode provider snapshot timeout for `/Users/moboudra` |
| `16:04:24.183` | 1201 | OpenCode provider subscribe starts |
| `16:04:24.183` | 1202 | OpenCode provider subscribe ready |
| `16:04:24.306` | 1214 | OpenCode server connected event |
| `16:04:25.933` | 1321 | OpenCode agent resumed from persistence |
| `16:04:33.666` | 1777 | workspace registries reconciled |
| `16:04:34.197` | 1783 | OpenCode heartbeat |
| `16:04:44.200` | 1789 | OpenCode heartbeat |
## Git Command Totals
Total git commands in the sliced startup: `444`.
| phase | commands | failures | summed process time |
| ------------------------------- | -------: | -------: | ------------------: |
| daemon bootstrap before listen | 13 | 4 | 445ms |
| post-listen before first client | 1 | 0 | 2020ms |
| client reconnect + reconcile | 430 | 71 | 120813ms |
| after reconcile | 0 | 0 | 0ms |
| total | 444 | 75 | 123278ms |
Summed process time is not wall-clock time. Many commands overlap.
## Git Command Categories
| category | commands | failures | summed process time | max duration |
| ---------------------------------------------------- | -------: | -------: | ------------------: | -----------: |
| ahead/behind: `rev-list --count ...` | 115 | 30 | 35815ms | 1557ms |
| refs: `show-ref --verify --quiet ...` | 86 | 2 | 14680ms | 1303ms |
| upstream config: `config --get branch.*` | 85 | 13 | 26437ms | 1624ms |
| root detection: `rev-parse --show-toplevel` | 80 | 30 | 24164ms | 1426ms |
| dirty status: `status --porcelain` | 50 | 0 | 12670ms | 2020ms |
| base divergence: `rev-list --left-right --count ...` | 28 | 0 | 9512ms | 1085ms |
What those categories mean in the app:
- Root detection: determine whether a cwd is inside a git repo and find its checkout root.
- Dirty status: show dirty/clean workspace state.
- Upstream config and ahead/behind: show branch tracking and sync state.
- Ref existence and base divergence: compare checkout branch against candidate base refs for checkout/PR status.
## Per-Workspace Git Work
Columns:
- `phase`: `pre/warm/reconnect/after`
- `cats`: `root/dirty/upstream/ahead/refs/base/other`
- `total_ms`: summed process time for that workspace
| workspace | cmds | fail | phase | cats | total_ms | max_ms | window | failing command shapes |
| ----------------------------------------------------------------------- | ---: | ---: | ---------- | ----------------- | -------: | -----: | --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `~/.paseo/worktrees/1luy0po7/fix-compaction-cancel-loading` | 33 | 9 | `0/0/33/0` | `3/3/3/9/12/3/0` | 8255 | 1460 | `16:03:52.908-16:04:24.019` | `3x config --get branch.fix-compaction-cancel-loading.remote`; `3x rev-list --count fix-compaction-cancel-loading..origin/fix-compaction-cancel-loading`; `3x rev-list --count origin/fix-compaction-cancel-loading..fix-compaction-cancel-loading` |
| `~/.paseo/worktrees/1luy0po7/hopeful-eel` | 33 | 9 | `0/0/33/0` | `3/3/3/9/12/3/0` | 8468 | 1544 | `16:03:53.245-16:04:27.334` | `3x config --get branch.feat/markdown-annotations.remote`; `3x rev-list --count feat/markdown-annotations..origin/feat/markdown-annotations`; `3x rev-list --count origin/feat/markdown-annotations..feat/markdown-annotations` |
| `~/.paseo/worktrees/1luy0po7/merry-ladybug` | 33 | 9 | `0/0/33/0` | `3/3/3/9/12/3/0` | 7154 | 1099 | `16:03:53.696-16:04:29.644` | `3x config --get branch.feat/mcp-configuration.remote`; `3x rev-list --count feat/mcp-configuration..origin/feat/mcp-configuration`; `3x rev-list --count origin/feat/mcp-configuration..feat/mcp-configuration` |
| `~/dev/paseo` | 30 | 0 | `2/0/28/0` | `5/5/10/10/0/0/0` | 7457 | 1624 | `16:03:48.171-16:04:27.284` | |
| `~/.paseo/worktrees/0vpo9h4b/dazzling-duck` | 27 | 0 | `0/0/27/0` | `3/3/6/6/6/3/0` | 7617 | 1269 | `16:03:51.918-16:04:23.971` | |
| `~/.paseo/worktrees/1luy0po7/epic-paseo-client-sdk` | 27 | 0 | `0/0/27/0` | `3/3/6/6/6/3/0` | 7428 | 1426 | `16:03:52.445-16:04:23.991` | |
| `~/.paseo/worktrees/1luy0po7/fix-provider-diagnostic-binary-resolution` | 27 | 0 | `0/0/27/0` | `3/3/6/6/6/3/0` | 7764 | 1091 | `16:03:52.681-16:04:23.971` | |
| `~/dev/emdash` | 22 | 6 | `0/0/22/0` | `2/2/2/6/8/2/0` | 3031 | 453 | `16:04:27.351-16:04:29.583` | `2x config --get branch.heads/main.remote`; `2x rev-list --count heads/main..origin/heads/main`; `2x rev-list --count origin/heads/main..heads/main` |
| `~/dev/opencode` | 22 | 4 | `0/0/22/0` | `2/2/2/6/8/2/0` | 2279 | 313 | `16:04:24.058-16:04:24.970` | `2x rev-list --count ecosystem-paseo..origin/ecosystem-paseo`; `2x rev-list --count origin/ecosystem-paseo..ecosystem-paseo` |
| `~/.paseo/worktrees/1luy0po7/integration-session-mcp-command-stack` | 18 | 0 | `0/0/18/0` | `3/3/6/6/0/0/0` | 7467 | 1242 | `16:03:53.781-16:04:23.971` | |
| `~/dev/blankpage/editor` | 18 | 0 | `2/0/16/0` | `3/3/6/6/0/0/0` | 2418 | 520 | `16:03:48.174-16:04:26.411` | |
| `~/dev/konbert/web` | 18 | 0 | `1/1/16/0` | `3/3/6/6/0/0/0` | 7324 | 2020 | `16:03:48.190-16:04:23.685` | |
| `~/dev/openchamber` | 12 | 0 | `0/0/12/0` | `2/2/4/4/0/0/0` | 1554 | 336 | `16:04:27.399-16:04:29.616` | |
| `~/dev/superset` | 12 | 0 | `0/0/12/0` | `2/2/4/4/0/0/0` | 1019 | 215 | `16:04:27.341-16:04:29.617` | |
| `~/dev/t3code` | 12 | 0 | `0/0/12/0` | `2/2/4/4/0/0/0` | 2761 | 588 | `16:04:27.356-16:04:29.603` | |
| `~/.paseo/worktrees/0vpo9h4b/breezy-toad` | 11 | 5 | `0/0/11/0` | `1/1/1/3/4/1/0` | 6465 | 1290 | `16:03:51.307-16:04:18.112` | `1x config --get branch.fix/user-delete-dark-mode.remote`; `1x rev-list --count fix/user-delete-dark-mode..origin/fix/user-delete-dark-mode`; `1x rev-list --count origin/fix/user-delete-dark-mode..fix/user-delete-dark-mode`; `2x show-ref --verify --quiet refs/remotes/origin/my-branch` |
| `~/.paseo/worktrees/1luy0po7/fix-archive-worktree-session-history` | 11 | 3 | `0/0/11/0` | `1/1/1/3/4/1/0` | 4303 | 757 | `16:03:52.539-16:04:19.011` | `1x config --get branch.fix-archive-worktree-session-history.remote`; `1x rev-list --count fix-archive-worktree-session-history..origin/fix-archive-worktree-session-history`; `1x rev-list --count origin/fix-archive-worktree-session-history..fix-archive-worktree-session-history` |
| `~/.paseo/worktrees/0vpo9h4b/codex-github-mention-implement-db-garbage` | 9 | 0 | `0/0/9/0` | `1/1/2/2/2/1/0` | 4986 | 1005 | `16:03:51.261-16:04:16.878` | |
| `~/.paseo/worktrees/1luy0po7/feat-find-in-pane` | 9 | 0 | `0/0/9/0` | `1/1/2/2/2/1/0` | 5273 | 1130 | `16:03:52.391-16:04:17.682` | |
| `~/.paseo/worktrees/1luy0po7/feat-voice-runtime-on-demand` | 9 | 0 | `0/0/9/0` | `1/1/2/2/2/1/0` | 5176 | 839 | `16:03:52.110-16:04:18.254` | |
| `~/.paseo/worktrees/steering-policy-refactor-detached` | 9 | 0 | `0/0/9/0` | `1/1/2/2/2/1/0` | 5993 | 1243 | `16:03:53.984-16:04:17.673` | |
| `~/dev/faro/main` | 6 | 0 | `2/0/4/0` | `1/1/2/2/0/0/0` | 4964 | 1603 | `16:03:48.168-16:04:03.748` | |
| `~/dev/paseo-cloud` | 6 | 0 | `2/0/4/0` | `1/1/2/2/0/0/0` | 2123 | 1154 | `16:03:48.159-16:03:56.377` | |
| `~/dev/assistant` | 3 | 3 | `1/0/2/0` | `3/0/0/0/0/0/0` | 85 | 29 | `16:03:48.165-16:04:24.048` | `3x rev-parse --show-toplevel` |
| `~/dev/benchmark/dashboard-2026-05-25/review` | 3 | 3 | `1/0/2/0` | `3/0/0/0/0/0/0` | 224 | 105 | `16:03:48.197-16:04:26.560` | `3x rev-parse --show-toplevel` |
| `~/dev/research/orchestrator-worker` | 3 | 3 | `1/0/2/0` | `3/0/0/0/0/0/0` | 285 | 144 | `16:03:48.194-16:04:26.575` | `3x rev-parse --show-toplevel` |
| `/tmp` | 2 | 2 | `0/0/2/0` | `2/0/0/0/0/0/0` | 113 | 77 | `16:04:27.388-16:04:27.471` | `2x rev-parse --show-toplevel` |
| `~/dev` | 2 | 2 | `0/0/2/0` | `2/0/0/0/0/0/0` | 86 | 58 | `16:04:27.384-16:04:27.457` | `2x rev-parse --show-toplevel` |
| `~/dev/benchmark/dashboard-2026-05-25/01-claude-opus` | 2 | 2 | `0/0/2/0` | `2/0/0/0/0/0/0` | 216 | 185 | `16:04:26.525-16:04:26.543` | `2x rev-parse --show-toplevel` |
| `~/dev/benchmark/dashboard-2026-05-25/02-codex-gpt55` | 2 | 2 | `0/0/2/0` | `2/0/0/0/0/0/0` | 98 | 68 | `16:04:26.353-16:04:26.554` | `2x rev-parse --show-toplevel` |
| `~/dev/benchmark/dashboard-2026-05-25/03-opencode-zai-glm51` | 2 | 2 | `0/0/2/0` | `2/0/0/0/0/0/0` | 209 | 158 | `16:04:26.512-16:04:26.576` | `2x rev-parse --show-toplevel` |
| `~/dev/benchmark/dashboard-2026-05-25/04-opencode-zen-minimax27` | 2 | 2 | `0/0/2/0` | `2/0/0/0/0/0/0` | 148 | 101 | `16:04:26.431-16:04:26.549` | `2x rev-parse --show-toplevel` |
| `~/dev/benchmark/dashboard-2026-05-25/05-opencode-zen-kimi26` | 2 | 2 | `0/0/2/0` | `2/0/0/0/0/0/0` | 231 | 172 | `16:04:26.517-16:04:26.577` | `2x rev-parse --show-toplevel` |
| `~/dev/benchmark/dashboard-2026-05-25/06-opencode-or-deepseek4pro` | 2 | 2 | `0/0/2/0` | `2/0/0/0/0/0/0` | 93 | 73 | `16:04:27.365-16:04:27.380` | `2x rev-parse --show-toplevel` |
| `~/dev/benchmark/dashboard-2026-05-25/07-opencode-zen-gemini35flash` | 2 | 2 | `0/0/2/0` | `2/0/0/0/0/0/0` | 78 | 66 | `16:04:27.363-16:04:27.375` | `2x rev-parse --show-toplevel` |
| `~/dev/benchmark/dashboard-2026-05-25/08-opencode-zen-gpt55` | 2 | 2 | `0/0/2/0` | `2/0/0/0/0/0/0` | 120 | 65 | `16:04:27.359-16:04:27.430` | `2x rev-parse --show-toplevel` |
| `~/dev/assistant/game` | 1 | 1 | `1/0/0/0` | `1/0/0/0/0/0/0` | 13 | 13 | `16:03:48.155-16:03:48.155` | `1x rev-parse --show-toplevel` |
## Git Failure Shape
There were 75 nonzero git exits.
Most failures were not timeouts. They were expected probe failures:
- Non-repo checks: `rev-parse --show-toplevel` fails for paths that are not git repositories.
- Missing upstream config: `config --get branch.<branch>.remote` fails for branches without configured upstream.
- Missing remote branch graph: `rev-list --count <branch>..origin/<branch>` fails when the remote branch/ref does not exist.
- Missing ref checks: `show-ref --verify --quiet refs/remotes/origin/my-branch` fails when a candidate ref does not exist.
The `~/dev/opencode` git failures are branch graph probes for `ecosystem-paseo` versus `origin/ecosystem-paseo`, not OpenCode provider startup failures.
## Inbound Client Work
Inbound session messages during the startup window:
| request | count |
| --------------------------------- | ----: |
| `client_heartbeat` | 19 |
| `checkout_pr_status_request` | 18 |
| `fetch_agents_request` | 11 |
| `fetch_workspaces_request` | 9 |
| `get_providers_snapshot_request` | 9 |
| `project_icon_request` | 9 |
| `fetch_agent_timeline_request` | 7 |
| `clear_agent_attention` | 6 |
| `list_terminals_request` | 5 |
| `subscribe_terminals_request` | 5 |
| `list_available_editors_request` | 2 |
| `subscribe_checkout_diff_request` | 2 |
| `checkout_status_request` | 1 |
| `fetch_agent_request` | 1 |
| `file_explorer_request` | 1 |
| `read_project_config_request` | 1 |
| `workspace_setup_status_request` | 1 |
Inbound by client:
| client | count | top work |
| ----------------------------------------------------------- | ----: | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Electron `cid_d555...`, origin `http://localhost:8082` | 68 | `checkout_pr_status_request:18`, `project_icon_request:9`, `clear_agent_attention:6`, `fetch_agent_timeline_request:4`, `fetch_workspaces_request:3`, `fetch_agents_request:3`, `get_providers_snapshot_request:3` |
| HeadlessChrome `cid_d39...`, origin `http://localhost:8081` | 13 | `client_heartbeat:4`, `fetch_workspaces_request:2`, `fetch_agents_request:2`, `get_providers_snapshot_request:2` |
| local web `cid_a2b...`, origin `http://localhost:6767` | 13 | `client_heartbeat:4`, `fetch_workspaces_request:2`, `fetch_agents_request:2`, `get_providers_snapshot_request:2` |
| Android `cid_24c...`, origin `http://10.0.2.2:6767` | 11 | `client_heartbeat:2`, `fetch_workspaces_request:2`, `fetch_agents_request:2`, `get_providers_snapshot_request:2` |
| `cid_70d...`, host `0.0.0.0:6767` | 2 | `fetch_agents_request:2` |
## Outbound Client Work
Outbound session messages during the startup window:
| message | count |
| ---------------------------------- | ----: |
| `providers_snapshot_update` | 129 |
| `workspace_update` | 81 |
| `checkout_status_update` | 76 |
| `agent_update` | 47 |
| `checkout_pr_status_response` | 18 |
| `fetch_agents_response` | 11 |
| `fetch_workspaces_response` | 9 |
| `get_providers_snapshot_response` | 9 |
| `project_icon_response` | 9 |
| `fetch_agent_timeline_response` | 7 |
| `list_terminals_response` | 5 |
| `terminals_changed` | 5 |
| `list_available_editors_response` | 2 |
| `subscribe_checkout_diff_response` | 2 |
| `checkout_status_response` | 1 |
| `fetch_agent_response` | 1 |
| `file_explorer_response` | 1 |
| `read_project_config_response` | 1 |
| `workspace_setup_status_response` | 1 |
Provider snapshot updates were large and repeated:
- Around lines 982-986: five `providers_snapshot_update` messages, each `215932` bytes.
- Around lines 997-1001: five `providers_snapshot_update` messages, each `215898` bytes.
- Around lines 1250-1254: five `providers_snapshot_update` messages, each `414735` bytes.
## Slow Requests
Slow requests logged during startup:
| time | request | duration | client | line |
| -------------- | --------------------------------: | -------: | --------------------- | ---: |
| `16:04:29.702` | `fetch_agent_timeline_request` | 39372ms | HeadlessChrome | 1767 |
| `16:04:29.702` | `fetch_agent_timeline_request` | 39212ms | Electron | 1768 |
| `16:04:29.702` | `fetch_agent_timeline_request` | 38914ms | local web | 1769 |
| `16:04:33.665` | `checkout_pr_status_request` | 20181ms | Electron | 1776 |
| `16:04:08.109` | `subscribe_checkout_diff_request` | 17618ms | Electron | 565 |
| `16:04:29.702` | `fetch_agent_timeline_request` | 16216ms | Electron | 1770 |
| `16:04:06.624` | `fetch_agent_timeline_request` | 16134ms | Electron | 524 |
| `16:04:29.396` | `checkout_pr_status_request` | 15911ms | Electron | 1671 |
| `16:04:29.256` | `checkout_pr_status_request` | 15772ms | Electron | 1651 |
| `16:04:29.149` | `checkout_pr_status_request` | 15665ms | Electron | 1638 |
| `16:04:29.054` | `checkout_pr_status_request` | 15569ms | Electron | 1628 |
| `16:04:28.932` | `checkout_pr_status_request` | 15448ms | Electron | 1611 |
| `16:04:28.809` | `checkout_pr_status_request` | 15324ms | Electron | 1601 |
| `16:04:28.672` | `checkout_pr_status_request` | 15188ms | Electron | 1582 |
| `16:04:28.555` | `checkout_pr_status_request` | 15071ms | Electron | 1567 |
| `16:04:28.421` | `checkout_pr_status_request` | 14936ms | Electron | 1556 |
| `16:04:28.323` | `checkout_pr_status_request` | 14839ms | Electron | 1549 |
| `16:04:28.324` | `checkout_status_request` | 14839ms | Electron | 1550 |
| `16:04:28.189` | `checkout_pr_status_request` | 14705ms | Electron | 1536 |
| `16:04:29.634` | `fetch_agents_request` | 14590ms | `0.0.0.0:6767` client | 1759 |
| `16:04:28.006` | `checkout_pr_status_request` | 14522ms | Electron | 1526 |
| `16:04:27.628` | `checkout_pr_status_request` | 14143ms | Electron | 1496 |
| `16:04:27.061` | `checkout_pr_status_request` | 13576ms | Electron | 1405 |
| `16:04:29.645` | `fetch_agents_request` | 13384ms | `0.0.0.0:6767` client | 1762 |
| `16:04:02.812` | `fetch_agent_timeline_request` | 12321ms | Electron | 440 |
| `16:04:25.740` | `checkout_pr_status_request` | 12256ms | Electron | 1309 |
| `16:04:25.352` | `checkout_pr_status_request` | 11867ms | Electron | 1296 |
| `16:04:04.217` | `fetch_agent_timeline_request` | 11751ms | Android | 462 |
| `16:04:25.155` | `checkout_pr_status_request` | 11671ms | Electron | 1284 |
| `16:04:23.196` | `fetch_agent_request` | 9711ms | Electron | 1070 |
| `16:04:17.563` | `project_icon_request` | 4079ms | Electron | 877 |
| `16:03:53.022` | `list_available_editors_request` | 2533ms | Electron | 254 |
| `16:04:15.703` | `project_icon_request` | 2218ms | Electron | 824 |
| `16:04:15.696` | `project_icon_request` | 2211ms | Electron | 822 |
| `16:04:15.694` | `project_icon_request` | 2209ms | Electron | 820 |
| `16:04:15.103` | `file_explorer_request` | 1618ms | Electron | 806 |
| `16:04:14.107` | `list_terminals_request` | 621ms | Electron | 764 |
| `16:03:50.945` | `list_terminals_request` | 614ms | HeadlessChrome | 156 |
The checkout PR requests are especially clustered: 18 Electron `checkout_pr_status_request` messages arrive together at `16:04:13.484`, lines 699-716. Their slow-request completions drain over the next ~20s, with `inflightRequests` dropping from 20 to 0.
## Provider Findings
### OpenCode
OpenCode provider snapshot refresh had two timeouts:
| time | line | cwd | error |
| -------------- | ---: | --------------------------- | --------------------------------------------- |
| `16:04:22.466` | 987 | `/Users/moboudra/dev/paseo` | `Timed out refreshing OpenCode after 30000ms` |
| `16:04:22.482` | 1002 | `/Users/moboudra` | `Timed out refreshing OpenCode after 30000ms` |
These are provider snapshot failures, not OpenCode agent resume failures.
The persisted OpenCode agent did resume:
| time | line | event |
| -------------- | ---: | ----------------------------------------------------- |
| `16:04:24.183` | 1201 | `provider.opencode.subscribe.start` |
| `16:04:24.183` | 1202 | `provider.opencode.subscribe.ready` |
| `16:04:24.306` | 1214 | raw event `server.connected` |
| `16:04:25.933` | 1321 | `Agent resumed from persistence`, provider `opencode` |
| `16:04:34.197` | 1783 | raw event `server.heartbeat` |
| `16:04:44.200` | 1789 | raw event `server.heartbeat` |
There are no `provider.opencode.subscribe.error` or OpenCode agent fatal errors in this slice.
OpenCode-related git:
- `~/dev/opencode` had 22 git commands.
- Four failed.
- The failed commands were branch graph probes for `ecosystem-paseo` versus `origin/ecosystem-paseo`.
- Those failures are git state/probe failures, not OpenCode provider process failures.
### Codex
Codex provider startup observations:
- `provider.codex.spawn` appears multiple times for provider snapshot/config discovery.
- A persisted Codex agent resumes successfully at `16:04:06.357`, line 518.
- Debug logs show failed reads of Codex saved config defaults, but these are debug-level and do not become provider startup warnings/errors in this slice.
- There are unhandled Codex trace event types such as remote-control/status and thread/goal status, but no Codex timeout or fatal provider startup failure in this slice.
### Claude
Claude agents resume successfully:
| time | line | client | agent |
| -------------- | ---: | -------- | -------------------------------------- |
| `16:04:02.540` | 434 | Electron | `f884552a-1383-4dba-8583-7ae0b6a62353` |
| `16:04:03.772` | 456 | Android | `0c89a057-05f2-4e23-9895-84c8e1952310` |
## What Work The App Asked For
The startup work visible in the app/server protocol is:
- Workspace list/sidebar hydration:
- `fetch_workspaces_request`, 9 total.
- This asks for the workspace list sorted by `activity_at desc`, usually page limit 200.
- On the server this triggers workspace git observer sync and workspace update flushing.
- Agent list and agent detail hydration:
- `fetch_agents_request`, 11 total.
- `fetch_agent_request`, 1 total.
- `fetch_agent_timeline_request`, 7 total.
- Timeline requests are among the slowest requests in this slice.
- Checkout/PR status UI:
- `checkout_pr_status_request`, 18 total, all Electron.
- `checkout_status_request`, 1 total.
- `subscribe_checkout_diff_request`, 2 total.
- These correspond to git snapshot consumers and are clustered during Electron reconnect.
- Provider/model/mode UI:
- `get_providers_snapshot_request`, 9 total.
- `providers_snapshot_update`, 129 outbound updates.
- OpenCode provider snapshot refresh times out twice during this flow.
- Workspace chrome:
- `project_icon_request`, 9 total.
- `file_explorer_request`, 1 total.
- Terminal panel:
- `list_terminals_request`, 5 total.
- `subscribe_terminals_request`, 5 total.
- `terminals_changed`, 5 outbound updates.
- Attention state:
- `clear_agent_attention`, 6 total.
- Some failures appear while clearing attention for persisted agents, but these are not provider startup failures.
## Concrete Waste-Looking Work, Without Optimizing Yet
The log shows repeated work in these exact forms:
- 444 git commands total, but only 14 complete before the first client hello. The rest are post-listen startup/client hydration work.
- Several workspaces get repeated full checkout snapshot patterns:
- three 33-command worktrees each get `3` root checks, `3` dirty checks, `3` upstream config probes, `9` ahead/behind probes, `12` ref checks, and `3` base divergence checks.
- three 27-command worktrees each get `3` root checks, `3` dirty checks, `6` upstream config probes, `6` ahead/behind probes, `6` ref checks, and `3` base divergence checks.
- `~/dev/paseo` gets `5` root checks, `5` dirty checks, `10` upstream config probes, and `10` ahead/behind probes.
- Electron sends 18 `checkout_pr_status_request` messages at the same timestamp, then they drain slowly over ~20s.
- Provider snapshot updates are broadcast very frequently: 129 outbound `providers_snapshot_update` messages, including large repeated payloads around 216KB and 415KB.
- OpenCode snapshot refresh times out twice after 30s, but the actual OpenCode agent connection/resume succeeds.
Again, this section names repeated work observed in the startup. It does not claim which repetition should be removed.

238
docs/docker.md Normal file
View File

@@ -0,0 +1,238 @@
# Running Paseo in Docker
Paseo publishes a container image for running the daemon on a server, VM, NAS,
or homelab box. The image also serves the bundled browser web UI, so one
container gives you both the daemon API and a self-hosted UI.
The image source lives in [`docker/`](../docker/).
## How it works
The official image:
- builds `@getpaseo/server` and `@getpaseo/cli` from source-built workspace tarballs
- runs the daemon as the non-root `paseo` user
- listens on `0.0.0.0:6767` inside the container
- enables the bundled daemon web UI with `PASEO_WEB_UI_ENABLED=true`
- stores daemon state and agent credentials under `/home/paseo`
- leaves agent CLIs out of the base image
Open the container's HTTP origin, for example `http://localhost:6767`, to load
the web UI. The served app receives a same-origin connection hint and connects
back to that daemon. Static UI files load without daemon auth; API and
WebSocket requests still require `PASEO_PASSWORD` when one is configured.
## Quick Start
```bash
docker run -d --name paseo \
-p 6767:6767 \
-e PASEO_PASSWORD=change-me \
-v "$PWD/paseo-home:/home/paseo" \
-v "$PWD:/workspace" \
ghcr.io/getpaseo/paseo:latest
```
Then open:
```text
http://localhost:6767
```
If you set `PASEO_PASSWORD`, enter the same password when adding the direct
daemon connection in the web UI or another Paseo client.
## Docker Compose
Use [`docker/docker-compose.example.yml`](../docker/docker-compose.example.yml):
```bash
cp docker/docker-compose.example.yml docker-compose.yml
$EDITOR docker-compose.yml
docker compose up -d
```
Minimal example:
```yaml
services:
paseo:
image: ghcr.io/getpaseo/paseo:latest
restart: unless-stopped
ports:
- "6767:6767"
environment:
PASEO_PASSWORD: "change-me"
volumes:
- ./paseo-home:/home/paseo
- ./workspace:/workspace
```
## Installing Agents
The base image does not preinstall Claude Code, Codex, OpenCode, Copilot, Pi, or
other agent CLIs. That keeps the default image small and avoids coupling Paseo
releases to third-party agent release cycles.
Create a child image for the agents you use:
```Dockerfile
FROM ghcr.io/getpaseo/paseo:latest
USER root
RUN npm install -g @openai/codex @anthropic-ai/claude-code opencode-ai
```
Build it:
```bash
docker build -f Dockerfile -t paseo-with-agents .
```
Then use `image: paseo-with-agents` in Compose.
Leave the child image user as root. The base entrypoint uses root only for
first-run directory setup, then drops the daemon and launched agents to the
non-root `paseo` user.
An example child image is in
[`docker/Dockerfile.agents.example`](../docker/Dockerfile.agents.example).
You can also mount credentials from the host or run agent login once inside the
container:
```bash
docker exec -it --user paseo paseo codex
docker exec -it --user paseo paseo claude
```
Agent credentials and config persist in `/home/paseo`, alongside daemon state.
Provider environment variables such as `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`,
`OPENAI_BASE_URL`, or `ANTHROPIC_BASE_URL` can be passed through `docker run -e`
or `compose.environment`; Paseo passes them to launched agents.
## Volumes
| Mount | Purpose |
| ------------- | ------------------------------------------------------------------------ |
| `/home/paseo` | Paseo state under `.paseo` plus agent config such as `.codex`, `.claude` |
| `/workspace` | Code that Paseo and launched agents can read and write |
The image defaults:
| Variable | Default |
| -------------- | -------------------- |
| `HOME` | `/home/paseo` |
| `PASEO_HOME` | `/home/paseo/.paseo` |
| `PASEO_LISTEN` | `0.0.0.0:6767` |
If you bind-mount host directories on Linux, make sure the container user can
write them. The built-in `paseo` user has uid/gid `1000:1000`. For a different
host uid/gid, either adjust ownership on the mounted directories or run the
container with Docker's `--user` / Compose `user:` option.
## Reverse Proxies
When serving Paseo behind a reverse proxy, forward normal HTTP requests and
WebSocket upgrades to the same daemon port.
Caddy example:
```caddy
paseo.example.com {
reverse_proxy 127.0.0.1:6767
}
```
Nginx example:
```nginx
server {
listen 443 ssl;
server_name paseo.example.com;
location / {
proxy_pass http://127.0.0.1:6767;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
If you reach the daemon by DNS name, set `PASEO_HOSTNAMES` so host-header
validation allows that name:
```yaml
environment:
PASEO_HOSTNAMES: "paseo.example.com,.lan"
```
IPs and `localhost` are allowed by default.
## Security
- Set `PASEO_PASSWORD` for any published port or network-reachable deployment.
- Prefer HTTPS at the reverse proxy for direct browser access.
- Use the Paseo relay for untrusted networks or mobile access when you do not
want to expose the daemon port directly.
- The container is the isolation boundary for agents. Agents can read and write
whatever you mount into `/workspace` and whatever credentials you place in
`/home/paseo`.
- The bundled web UI static files are public on the daemon origin. The daemon
API and WebSocket remain protected by password auth when configured.
See [SECURITY.md](../SECURITY.md) for the daemon trust model.
## Building Locally
```bash
docker build -f docker/base/Dockerfile -t paseo:local .
```
To assert the source tree version while building:
```bash
docker build \
--build-arg PASEO_VERSION=0.1.102 \
-t paseo:0.1.102 \
-f docker/base/Dockerfile \
.
```
The Docker workflow builds the image on pull requests and on `main` as a
non-publishing check. Stable `vX.Y.Z` tag pushes publish
`ghcr.io/getpaseo/paseo:X.Y.Z` and `ghcr.io/getpaseo/paseo:latest`. Beta tags
publish only the exact prerelease tag, such as
`ghcr.io/getpaseo/paseo:0.1.102-beta.1`, and do not update `latest`.
To replace a Docker image in place without rebuilding desktop, APK, or EAS
mobile release artifacts, dispatch the Docker workflow manually instead of
pushing a `v*` release tag:
```bash
gh workflow run docker.yml \
--ref main \
-f paseo_version=0.1.102-beta.1 \
-f publish=true
```
Manual Docker publishes require an explicit `paseo_version`. The workflow builds
from the checked-out source tree and publishes only the exact prerelease image
tag for prerelease versions.
The published image is multi-arch for `linux/amd64` and `linux/arm64`.
## Troubleshooting
- **The web UI loads but cannot connect**: if `PASEO_PASSWORD` is set, add a
direct connection with the same password.
- **403 Host not allowed**: set `PASEO_HOSTNAMES` to the DNS names you use.
- **Provider not available**: install that agent CLI in a child image or mount a
runtime where the binary is on `PATH`.
- **Permission errors in `/workspace`**: make the mounted directory writable by
uid/gid `1000:1000`, or run the container as the host uid/gid.
- **Logs**: inspect `docker logs paseo` or
`/home/paseo/.paseo/daemon.log` inside the container.

146
docs/expo-router.md Normal file
View File

@@ -0,0 +1,146 @@
# Expo Router
Paseo's mobile route tree is fragile because Expo Router and React Navigation do
not fail loudly when a nested native route is mounted under the wrong layout. The
usual symptom is a white or blank native screen with no JavaScript crash.
Read this before changing `packages/app/src/app`, startup routing, remembered
workspace restore, or active workspace selection.
## Ownership
Each layout owns only the routes directly inside its directory.
- The root layout registers `h/[serverId]`.
- The root layout does not register host leaf routes such as
`h/[serverId]/workspace/[workspaceId]`, `h/[serverId]/open-project`, or
`h/[serverId]/index`.
- `packages/app/src/app/h/[serverId]/_layout.tsx` owns the host leaves with
relative screen names: `index`, `workspace/[workspaceId]/index`,
`agent/[agentId]`, `sessions`, `open-project`, and `settings`.
Expo Router warns with `[Layout children]: No route named ...` when a layout
registers grandchildren. Treat that warning as a route-tree bug. On native, that
shape can leave a nested index route mounted without its local dynamic params and
render a blank screen.
## Startup
The root `/` route chooses a host boundary. It does not jump directly into a host
leaf.
- Good: `/` -> `/h/[serverId]`
- Bad: `/` -> `/h/[serverId]/workspace/[workspaceId]`
`/h/[serverId]` is the host home route. The host index restores the last
remembered workspace for that host after the remembered selection has hydrated
and the workspace has not been proven missing. If there is no restorable
workspace, it goes to global `/open-project`.
This restore is based on the last navigated workspace, not current connection
status. Do not redirect to another online host just because the remembered host
is still connecting or offline; the workspace screen owns that offline/loading
state.
This split is deliberate. The host layout must mount first so native local
dynamic params exist before any nested workspace leaf is selected.
## App-Wide Route Hops
When app-wide routes such as `/new`, `/settings`, or `/sessions` navigate back
into a host workspace, use `navigateToWorkspace()`. Do not make the caller
branch on its current route.
Pass only `serverId` and `workspaceId` for normal attention-aware navigation.
When the action names a specific tab, pass it as `target`; that explicit choice
is authoritative. Callers should not choose between separate route and tab
navigation APIs.
The root stack owns `h/[serverId]`; the host stack owns
`workspace/[workspaceId]/index`. Repeated global-route hops must `POP_TO` the
root host route and pass the nested workspace screen when a host route is
already mounted, or Expo Router can append extra hidden workspace deck entries.
The workspace navigation helper inspects the mounted navigation state to make
that decision; if no host route is mounted yet, it falls back to ordinary route
navigation.
Those hidden entries are not harmless: composer floating panels can measure
against the wrong deck and disappear offscreen.
Hidden host routes may keep their local params while an app-wide route is
foregrounded. Active-workspace observers must prefer the current pathname and
only use local param fallback during cold mount (`/` or empty pathname), or a
hidden workspace can overwrite the remembered workspace before Settings or
History returns.
## Params
Required dynamic params belong to the matched route.
Do not paper over missing required params by reading global params in the leaf.
If `useLocalSearchParams()` misses a required param, fix layout ownership or the
startup route shape.
Use the host route context for host-owned leaves that need the host id after
`h/[serverId]/_layout.tsx` has matched. Do not make a leaf recover from an
unmatched tree by guessing from global state.
## App Directory
Keep non-route modules out of `src/app`. Expo Router treats ordinary `.ts` and
`.tsx` files there as routes, which produces `missing the required default
export` warnings and pollutes the route tree.
Put shared route policy in `src/navigation`, `src/utils`, stores, or another
non-route directory.
## Native Stack
Keep workspace identity and retention outside native-stack `getId` and
`dangerouslySingular`. Expo Router maps `dangerouslySingular` to React
Navigation `getId`, and `getId` has broken Android native-stack/Fabric by
reordering an already-mounted workspace screen.
Use `ThemedStack` from `packages/app/src/navigation/themed-stack.tsx` for every
Expo Router stack. React Navigation otherwise paints each native stack screen
with its light default background. A screen-level wrapper can hide that surface
while settled, but Android may expose it for one frame when navigation crosses
from a nested stack to its parent stack. This is especially visible when an
app-wide route such as `/new` opens from a dark workspace.
Do not read the active theme with `useUnistyles()` in a layout to build
`screenOptions`. `ThemedStack` keeps that third-party prop theme-reactive through
a small `withUnistyles` boundary without subscribing the route tree itself to
every Unistyles runtime update.
## Regression Shape
Pure helper tests are useful but not enough. The failure mode here is native
route-tree state, so a real regression should launch native with seeded persisted
state:
1. Seed `paseo:last-workspace-route-selection` with a valid
`{ serverId, workspaceId }`.
2. Launch the native app cold.
3. Assert a real screen is visible, not the blank tree.
4. Assert no `[Layout children]` warning appears.
The pure policy tests should still enforce the boundary split:
- root startup with a saved workspace returns `/h/[serverId]`;
- host index with the same saved workspace returns
`/h/[serverId]/workspace/[workspaceId]`;
- host index with no restorable workspace returns `/open-project`.
## Checklist
Before landing route changes:
- [ ] Did you change `packages/app/src/app`? Re-read this file.
- [ ] Did you touch remembered workspace restore? Keep root on `/h/[serverId]`.
- [ ] Did a route return to a workspace? Use `navigateToWorkspace()` and pass a
`target` when the action names a specific tab.
- [ ] Did you add a route? Register it in the layout that directly owns it.
- [ ] Did `useLocalSearchParams()` lose a required param? Fix the route tree.
- [ ] Did native show a blank screen without a crash? Suspect route ownership
before stores, themes, or rendering.

View File

@@ -20,6 +20,29 @@ input focused while its scrollable list lives in a Portal. There is no shared
"floating panel" primitive yet — when a fifth use case shows up we can revisit;
until then prefer copying the closest file and trimming.
## Popover width contract
Combobox desktop popovers are never narrower than their trigger, and they grow
with content up to a ceiling that is never below the trigger:
```ts
const floor = Math.max(desktopMinWidth ?? 0, referenceWidth ?? 200);
const frameStyle = { minWidth: floor, maxWidth: Math.max(400, floor) };
```
`desktopMinWidth` is an explicit floor-raiser. It does not cap width, and the
trigger still wins when it is wider. Changing this default requires re-verifying
every consumer listed here.
Consumers: `composer/agent-controls/mode-control.tsx`,
`composer/agent-controls/index.tsx`, `composer/index.tsx`,
`components/combined-model-selector.tsx`, `components/hosts/host-picker.tsx`
(including `components/hosts/host-filter.tsx`), `components/branch-switcher.tsx`,
`components/left-sidebar.tsx`, `components/ui/select-field.tsx` (schedule form),
`screens/new-workspace-screen.tsx` plus `screens/new-workspace/project-picker.ts`,
`components/import-session-sheet.tsx`, `screens/workspace/workspace-screen.tsx`,
`screens/settings-screen.tsx`, and `screens/project-settings-screen.tsx`.
## Gotcha 1 — Android touch hit-test by parent bounds
On Android, a child View whose bounds fall outside its parent's bounds renders
@@ -60,12 +83,11 @@ quietly relying on:
Gate `visible` on a screen-focus signal. For panes inside `agent-panel`, the
`isPaneFocused` prop already exists and flips on pane switches; pass
`visible={isYourOwnVisible && isPaneFocused}`.
- **Transforms.** The composer is wrapped in a Reanimated `Animated.View` with
`translateY: -keyboardShift` (see `use-keyboard-shift-style.ts`). The chat
content has the same transform applied (`agent-panel.tsx:939`). They move
together because they share the SharedValue. A portal'd popover is outside
the composer tree — it does not get that transform unless you apply it
yourself.
- **Transforms.** `KeyboardShiftProvider` owns the canonical keyboard shift
SharedValue, and `useKeyboardShiftStyle()` only adapts that value into
translate/padding styles. The composer and chat content must both read that
provider-owned value. A portal'd popover is outside the composer tree — it
does not get that transform unless you apply it yourself.
- **Layering.** The default root host renders after app content, so it sits
above compact sidebars. Content overlays that must sit below sidebars should
use the current `FloatingPanelPortalHost`.
@@ -86,7 +108,8 @@ with Reanimated worklets the result is not always stable.
If the panel cannot stay inside the transformed ancestor, do not try to track
the keyboard by re-measuring on every frame. Instead,
**slave the popover's transform to the same SharedValue the composer uses**:
**slave the popover's transform to the same `KeyboardShiftProvider` SharedValue
the composer uses**:
1. Snapshot `openShift = shift.value` at the moment you measure the anchor.
2. Apply `useAnimatedStyle(() => ({ transform: [{ translateY: openShift.value - shift.value }] }))`
@@ -95,7 +118,10 @@ the keyboard by re-measuring on every frame. Instead,
When `shift` equals `openShift`, the translate is 0 and the popover sits at
the measured position. When the keyboard moves afterward, the delta translates
the popover by exactly the amount the composer translates. They move in
lockstep, no re-measurement needed.
lockstep, no re-measurement needed. Do not call
`useReanimatedKeyboardAnimation()` directly for app UI offset policy; Android
can briefly report a stale nonzero height with closed progress, and the shared
provider is where that is normalized.
Re-measure on `Keyboard.addListener('keyboardDidShow'|'keyboardDidHide')` only
to refresh the snapshot if the keyboard was mid-transition when the popover
@@ -169,6 +195,11 @@ on that attach races Gorhom's dismiss path and leaves the modal unable to reopen
Track an explicit phase (`closed` / `presenting` / `presented` / `dismissing`) and
ignore ref churn while dismissing.
Do not treat `onChange(-1)` as a close by itself. In a stacked
`BottomSheetModal`, `-1` can also mean the sheet is temporarily hidden under
another pushed sheet. Close React state from `onDismiss`; use `onChange` only to
track phase.
## Recipe for a new anchored panel
Before you write a new one, ask:

190
docs/forge-providers.md Normal file
View File

@@ -0,0 +1,190 @@
# Adding a Git Forge to Paseo
Paseo's forge layer is a registry/manifest system. A forge is a runtime concern:
shared protocol messages carry neutral/open facts, the server adapter owns
behavior, and the app owns bundled presentation/runtime interpretation.
The maintainer litmus test is the rule of thumb:
> Adding a new forge means adding files in a new directory/module that implement
> an interface, plus one entry in the centralized registry/manifest for that
> package.
## The Three Registrations
For forge `acme`, the expected end state is:
1. **Protocol manifest** - optional, only when the forge should be presented by
shared manifest data. Add one `ForgeDefinition` to
`packages/protocol/src/forge-manifest.ts`.
2. **Server adapter** - add `packages/server/src/services/acme-service.ts`
implementing `ForgeService`, any adapter-owned fact types/guards/constants
beside it, and one `defaultForgeRegistry` entry in
`packages/server/src/services/forge-registry.ts`.
3. **App modules** - a forge splits into a pure logic half and a view half so
logic consumers (URL builders, merge-capability, native checks, and the
Node-based e2e harness) never pull the client rendering stack:
- `packages/app/src/git/forges/acme.ts` - logic: `id`, optional `urlGrammar`,
optional `facts` (schema, merge-capability, native-check fallbacks). No
React/React-Native imports. Register in `CLIENT_FORGE_LOGIC_MODULES` in
`packages/app/src/git/forges/index.ts`.
- `packages/app/src/git/forges/acme.view.tsx` - view: `icon` (SVG component
under `packages/app/src/components/icons/`), optional `brandColor`, optional
`paneContributions`. Register in `CLIENT_FORGE_VIEW_MODULES` in
`packages/app/src/git/forges/view.ts`.
There should be no protocol typed-union arm, no central app icon/color/url/facts
map, and no central server union of known forge facts.
## Protocol
`forgeSpecific` on PR status is an open envelope:
```ts
z.object({ forge: z.string() }).passthrough();
```
The `forgeSpecific.forge` field is a **facts-family tag**, not the workspace
brand id. Gitea, Forgejo, and Codeberg can all emit `forgeSpecific.forge ===
"gitea"` when they share the same facts shape, while top-level `status.forge`
keeps the brand id (`"gitea"`, `"forgejo"`, `"codeberg"`).
Protocol does not validate per-forge fact fields. Consumers that understand a
facts family validate at runtime with their own schema/guard. Unknown or
schema-mismatched facts render neutrally instead of failing the whole message
parse. This is the version-skew win: an old client can receive facts from a
newer forge and still show the PR/MR in a neutral state.
Shipped GitHub compatibility stays separate:
- `status.github` remains accepted for released peers.
- The server keeps the `COMPAT(forgeSpecific)` mirror that copies GitHub facts
into `status.github` for older clients.
- Do not add a compatibility shim unless a released peer (<= 0.1.102) can
actually produce the state.
## Server
The server-wide status type only promises:
```ts
type ForgeSpecificStatusFacts = { forge: string } & Record<string, unknown>;
```
Adapter-owned files define the typed shapes and guards, for example
`github-facts.ts`, `gitlab-facts.ts`, and `gitea-facts.ts`. The adapter can keep
strong internal types for construction and command guards, but shared server
code must not grow a central list of forge fact arms.
Register the adapter in `defaultForgeRegistry` with:
- `createService`
- `matchesHost` from manifest `cloudHosts`
- `probeHost` when self-hosted/Enterprise detection is supported
Current change-request lookup uses two identities deliberately:
- An open PR/MR belongs to the checkout when its head branch and head repository
match. Its remote head SHA may differ because the checkout can be ahead,
behind, or contain commits that have not been pushed yet.
- A merged or closed PR/MR belongs to the checkout only when its recorded head
SHA exactly matches the checkout's current `HEAD`. Branch names are reusable;
selecting the newest terminal request by branch alone can silently attach an
old promotion or feature request to new work.
Thread the checkout head SHA through adapter cache and poll identities as well
as the lookup itself. Otherwise a commit made on the same branch can inherit the
previous commit's cached terminal status until the cache expires.
Cloud hosts in the manifest are a bounded public-host list, not a self-host
allowlist. Self-hosted detection is a trust gate: Paseo only talks to a forge
host that is either a known cloud host or one the CLI is already authenticated
to. Adapter probes must not make anonymous HTTP requests to remote-derived
hosts, and adapters must not route credentials to an unauthenticated host.
## App
Each app forge splits into two modules so pure logic never imports the client
rendering stack:
`acme.ts` exports a `ClientForgeLogicModule`:
- `id`
- optional `urlGrammar`
- optional `facts` registration (schema, merge-capability, native-check fallbacks)
`acme.view.tsx` exports a `ClientForgeViewModule`:
- `id`
- `icon`
- `brandColor` (`null` for neutral; GitHub intentionally uses `null`)
- optional `paneContributions`
Two registries live under `packages/app/src/git/forges/`:
`CLIENT_FORGE_LOGIC_MODULES` (`index.ts`) drives URL grammar, merge-capability
derivation, and native fallback checks; `CLIENT_FORGE_VIEW_MODULES` (`view.ts`)
drives icon/color lookup and PR-pane contributions. Logic consumers must import
the logic registry only — importing the view registry (or a `.view.tsx` module)
from a logic path pulls react-native and breaks the Node-based e2e harness.
Per-forge brand colors live on the module, not in `styles/theme.ts`. Use the
Unistyles-safe pattern from `docs/unistyles.md`: no `useUnistyles()`. Brand icon
call sites use `withUnistyles` and a `uniProps` mapping such as:
```ts
(theme) => ({ color: theme.colorScheme === "light" ? colors.light : colors.dark });
```
Facts modules use one source of truth: a Zod schema. Helpers like
`defineForgeFacts`, `defineNativeFallbackCheck`, and `definePaneContribution`
derive guards from `schema.safeParse` and re-parse before invoking typed
derivers/renderers. That keeps typed derivers away from the open wire envelope.
## Checklist
To add `acme`:
1. Add `acme` to `FORGE_DEFINITIONS` if the shared manifest should know its
label, nouns, icon kind, sign-in CLI, or cloud hosts.
2. Add `acme-service.ts` implementing `ForgeService`.
3. Add `acme-facts.ts` beside the adapter if it reports native facts.
4. Add one `defaultForgeRegistry` entry.
5. Add `packages/app/src/git/forges/acme.ts` (logic) and
`packages/app/src/git/forges/acme.view.tsx` (view).
6. Add one `CLIENT_FORGE_LOGIC_MODULES` entry (`index.ts`) and one
`CLIENT_FORGE_VIEW_MODULES` entry (`view.ts`).
7. Add/update the icon component only if the client bundle should show a brand
mark.
8. If the forge's CI/data model does not fit an existing required
`ForgeService` field, widen the shared interface (plus the protocol schema
and its guards) instead of faking a value — e.g. Gitea Actions runs carry no
check-run id, so `GetCheckDetailsOptions.checkRunId` became optional with
`workflowRunId` as the alternative address. Expect this step to touch
`forge-service.ts`, `messages.ts`, and the call-site guards of the other
adapters. Widening a shared field is not forge-local: it also affects the
already-shipped forges/GitHub call sites and the capability-gated RPC (e.g.
`forgeCheckDetails`), so verify every consumer rather than assuming the change
only reaches the new adapter.
9. Run targeted tests: manifest/registry/resolver, the adapter test, protocol
checkout PR schema, app forge URL/presentation tests, app merge capability,
and any PR-pane native data tests touched.
Run `npm run typecheck` after each implementation slice. If protocol or client
declarations are stale, run `npm run build:client`; if server/CLI declarations
are stale, run `npm run build:server`.
## Gotchas
- GitHub is a normal registry entry plus released compatibility shims. Keep all
real shims tagged with `COMPAT(name)`.
- Gitea-family facts use `forgeSpecific.forge === "gitea"` even when the
top-level brand is Forgejo or Codeberg.
- Brand icons are bundled React components, so they cannot come from protocol
manifest data.
- Source URL grammars are app-side because blob/tree path syntax is
forge-specific. If a forge has no grammar, omit the "Open on ..." source link
rather than constructing a wrong URL.
- GitLab pipeline status constants belong to the GitLab adapter/client module,
not protocol.

98
docs/forms.md Normal file
View File

@@ -0,0 +1,98 @@
# Forms
The paved road for building forms in the app. The schedule form is the golden
example; when building or fixing any form, copy its shape, not the shape of
whatever screen you happen to be near.
Golden example files:
- `packages/app/src/schedules/schedule-form-model.ts` (+ `.test.ts`) — the model
- `packages/app/src/schedules/use-schedule-form-model.ts` — model lifetime adapter
- `packages/app/src/schedules/use-schedule-form-provider-snapshot.ts` — async input adapter
- `packages/app/src/components/schedules/schedule-form-sheet.tsx` — render + intent dispatch
- `packages/app/src/schedules/aggregated-schedules.ts` / `hooks/use-schedules.ts` — load-state gating
- `packages/app/e2e/schedules-*.spec.ts` — the behavioral contract
## The form model
Every non-trivial form gets a **plain TypeScript model** — zero React imports:
- `openXxxForm(snapshot)` **constructs** a fresh instance from declared inputs
(mode, the record being edited, hosts, defaults). Edit mode seeds every value
AND display from the snapshot — never from a previous instance.
- **Commands** mutate (`setHost`, `setProject(value, display)`, `setModel`, …).
Derived state (disclosure, canSubmit, displays) is recomputed inside the
model on every publish.
- `close()` destroys the instance. `subscribe`/`getState` feed one
`useSyncExternalStore` in the component.
The component renders state and dispatches intent. That is all it does.
### Lifecycle rules (each one killed a real shipped bug)
1. **Fresh mount per open.** The sheet returns `null` when not visible and
mounts the open form with a `key` derived from mode + record identity.
A long-lived component instance shared across create/edit is how edit
contaminated create.
2. **Construct the model ONCE per mount**`useState(() => openXxxForm(snapshot))`.
NEVER `useMemo(() => open(...), [snapshot])`: the snapshot's identity depends
on live data (projects, hosts, preferences), and any background churn — e.g.
a scheduled run creating a workspace — would reconstruct the model and wipe
the user's in-progress input.
3. **Late data is an explicit model input, not a reconstruction.**
`applyProviderSnapshot(serverId, …)`, `applyProjectTargets(…)`,
`applyHosts(…)`. Adapters pipe identity changes into these with mechanical
effects. Input plumbing is fine; orchestration effects are not — the sheet
itself has zero `useEffect`/`useRef`, and that is the target for every form.
4. **Resolution is explicit model state, per host** (`idle | pending |
complete`), keyed off the opened snapshot's serverId. Waiting for data is a
state you can render, not an effect race.
5. **Displays are owned state.** The selected option's label is captured at
selection/seed time (`setProject(value, display)`), never re-derived from a
live options list — list churn must not flicker or blank a selection.
6. **Disclosure is derived in the model** from user intent
(host → project → model → thinking/mode), so fields cannot pop in from
cache timing.
## Form kit
- Compose `Field` / `SelectField` / `FormTextInput` / `SegmentedControl` /
`Switch` from `components/ui/`. Geometry (heights, padding, radii, focus/hover
states) is owned by `components/ui/control-geometry.ts` — controls never
declare their own, and screens never nudge global component styles to align
a row.
- The form declares one size for all fields: `sm` on desktop, `md` compact
(`useIsCompactFormFactor`).
- Availability hierarchy: a field whose capability doesn't apply is **hidden**
(isolation on a non-git project — same gating as New Workspace), not rendered
disabled with an explanation. Disabled-with-a-reason `hint` is only for
transient states the user can resolve.
- Copy is opt-in and rare. No hint/subtext unless the maintainer approved the
exact string; validation errors are the exception. State a fact (like the
timezone) once — never in a preview line AND a helper line.
- `useUnistyles` is banned (see docs/unistyles.md); lint enforces.
## Data gating
Aggregate hooks return a discriminated load state:
```ts
type AggregateLoadState<T> =
| { status: "connecting" } // an answer may still be pending
| { status: "loading" }
| { status: "loaded"; data: T[] };
```
Empty states are only typeable inside `loaded` — a fetch that "succeeded"
before hosts connected is `connecting`, not empty. Query keys carry real fetch
inputs (host set, connection statuses), never synthetic version counters.
## Anti-patterns (reject in review on sight)
- `useEffect` choreography impersonating construct/hydrate/resolve/destroy.
- One mounted form instance serving create and edit.
- `useMemo`-keyed model construction on live-data identity.
- Selected labels derived from live query lists.
- `isLoading`/`isEmpty` boolean bags where a load-state union belongs.
- Conditional mounting of hint/error rows that shifts layout (subtext renders
only when present, but the pattern for that lives in `Field`, not ad hoc).

View File

@@ -2,31 +2,43 @@
Authoritative terminology. UI label wins. Don't invent synonyms; use what's here.
- **Project** — Logical grouping of workspaces sharing a git remote (or main repo root). UI: "Project" / "Add project". Code: `ProjectSummary` (`packages/app/src/utils/projects.ts:22`), `projectKey` (`packages/server/src/server/workspace-registry-model.ts:16`). Forbidden: "Repo", "Repository" as UI label.
- **Workspace** — One concrete `cwd` on one daemon, with git state; belongs to exactly one project. UI: "Workspace". Code: `WorkspaceDescriptorPayload` (`packages/protocol/src/messages.ts:2178`). Don't confuse with: Branch (one branch can back many workspaces via worktrees). Forbidden: "Folder", "Directory" as UI label.
- **Workspace kind** — `"directory" | "local_checkout" | "worktree"`. Code: `PersistedWorkspaceKind` (`packages/server/src/server/workspace-registry-model.ts:8`).
- **Agent** — One AI coding agent run on a daemon (one provider, one model, one cwd, one timeline). UI: "Agent" / "New Agent". Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`). Forbidden: "Task", "Job", "Run".
- **Project** — A stable, exact selected-root record. New IDs are opaque `prj_<16 hex>` values; older remote-shaped and path-shaped IDs remain readable compatibility records. Git facts can update mutable kind metadata but never project identity, root, or default display name. UI: "Project" / "Add project". Forbidden: "Repo", "Repository" as UI label.
- **Workspace** — One concrete `cwd` on one daemon, with git state; belongs to exactly one project. Its `id` is opaque workspace identity; its `cwd` is the filesystem directory. UI: "Workspace". Code: `WorkspaceDescriptorPayload` (`packages/protocol/src/messages.ts:2178`). Don't confuse with: Branch (one branch can back many workspaces via worktrees). Forbidden: "Folder", "Directory" as UI label.
- **Archive workspace** — Removes one workspace from active use and archives everything it owns. UI, CLI, and MCP always say "Archive workspace", regardless of backing. The daemon leaves ordinary directories intact and removes a Paseo-owned worktree only when no active workspace still references it.
- **Workspace kind** — `"directory" | "local_checkout" | "worktree"`. The git-derived, persisted property of a workspace, used across its lifetime (archive safety, sidebar, grouping). Derived from the cwd's git reality by `deriveWorkspaceKind` in `workspace-registry-model.ts`, not stored from a user choice. Don't confuse with **Isolation** (the create-time intent).
- **Isolation** — Create-time choice for a new workspace: reuse the existing checkout (**Local**) or cut a dedicated git worktree (**New worktree**). A transient setup input, also remembered as a create-form preference; it is not a workspace property. UI: "Isolation" control on the New Workspace screen. Code: `isolation` (`"local" | "worktree"`), `useWorkspaceIsolation` (`packages/app/src/screens/new-workspace-screen.tsx`); persisted as `FormPreferences.isolation` (`packages/app/src/create-agent-preferences/preferences.ts`). Distinct from **Workspace kind**, which is the git-derived property the intent produces (Local → `local_checkout` or `directory` by git-ness; New worktree → `worktree`). On the wire it is the create request's `source.kind` (`directory | worktree`, `packages/protocol/src/messages.ts:1693`).
- **Agent** — See **Agent session**. UI still says "Agent" / "New Agent" in places, but moving toward **Agent session** as the canonical term. Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`). Forbidden: "Task", "Job", "Run".
- **Daemon** — Local Paseo server process; identified by `serverId`. UI: "Daemon" (system contexts only). Code: `serverId` in `ServerInfoStatusPayloadSchema` (`packages/protocol/src/messages.ts:1936`), `DaemonClient` (`packages/client/src/daemon-client.ts`).
- **Host** — Client-side connection profile pointing at a daemon; bundles one or more `HostConnection`s. UI: "Host" / "Add host" / "Switch host". Code: `HostProfile` (`packages/app/src/types/host-connection.ts:37`). Forbidden: "Connection" (means `HostConnection`, not host).
- **Project host entry** — One row in a project for a single (project, daemon) pair, aggregating that daemon's workspaces in the project. Internal. Code: `ProjectHostEntry` (`packages/app/src/utils/projects.ts:11`). Don't introduce "Checkout" as a synonym.
- **Placement** — One workspace's relationship to its project (projectKey, projectName, git checkout snapshot). Internal. Code: `ProjectPlacementPayload` (`packages/protocol/src/messages.ts:2113`).
- **Placement** — One workspace's stable foreign-key relationship to its project plus its git checkout snapshot. Internal. An explicit creation `projectId` is authoritative when active.
- **Branch** — Plain git branch. UI: "Switch branch". Code: `currentBranch` in `WorkspaceGitRuntimePayloadSchema` (`packages/protocol/src/messages.ts:2136`); `BranchSwitcher` (`packages/app/src/components/branch-switcher.tsx`).
- **Worktree** — Paseo-managed git worktree (`~/.paseo/worktrees/{name}`); also a `workspaceKind` value. UI: CLI + `paseo.json` keys (`worktree.setup`, `worktree.teardown`) only. Code: `ProjectCheckoutLiteGitPaseoPayload` (`packages/protocol/src/messages.ts:2092`); CLI `paseo worktree` (`packages/cli/src/commands/worktree/index.ts:8`). Forbidden: "Checkout" as a synonym.
- **Repository / Remote** — Internal git inputs (`remoteUrl`, `mainRepoRoot`) used to derive `projectKey`. No UI label.
- **Session** — Per-client connection to a daemon. Internal. Code: `Session` (`packages/server/src/server/session.ts`). Don't confuse with: provider-side agent session log.
- **Forge** — Git hosting service behind Paseo's change-request features: GitHub, GitLab, Gitea, Forgejo, or a future registered adapter. Code: `ForgeService`, `forge-registry`, `forge-resolver`. Use `forge` for internal abstraction and registry IDs; use concrete forge names only when a behavior or RPC is forge-specific.
- **Change request** — Forge-neutral term for a proposed branch-to-branch code change. UI normally renders the forge noun instead: GitHub/Gitea/Forgejo "PR", GitLab "MR". Code: `forge_change_request` attachments, `checkoutSource: { kind: "change_request" }`, and PR/MR status payloads.
- **MR** — GitLab merge request. UI label for GitLab change requests only; do not use MR for GitHub/Gitea/Forgejo.
- **Worktree** — Paseo-managed git worktree (`~/.paseo/worktrees/{name}`); also a `workspaceKind` value. User-facing creation treats it as the `worktree` workspace isolation choice. Code and `paseo.json` retain worktree terminology for git lifecycle implementation. Forbidden: "Checkout" as a product synonym.
- **Repository / Remote** — Internal Git observations. They may affect mutable kind/branch metadata but never project identity, root, display name, or workspace membership. No UI label.
- **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.
- **Provider** — Agent backend (Claude Code, Codex, Copilot, OpenCode, Pi). UI: "Provider". Code: `ProviderSnapshotEntry` (`packages/protocol/src/messages.ts:198`).
- **Provider** — Agent backend (Claude Code, Codex, Copilot, OpenCode, Pi, Oh My Pi). UI: "Provider". Code: `ProviderSnapshotEntry` (`packages/protocol/src/messages.ts:198`).
- **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 remote agents. UI: CLI only (`paseo schedule`). Code: `ScheduleCreateRequest` (re-exported from `packages/protocol/src/messages.ts`). Don't confuse with: Loop (iterative re-execution of one agent).
- **Schedule** — Cron-style trigger that creates new agents. UI: CLI/MCP (`paseo schedule`, `create_schedule`). Don't confuse with: Heartbeat (cron prompt back into the same agent) or Loop (iterative re-execution of one 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.
- **Mode** — Provider-specific operational mode (plan, default, full-access, …). UI: icon-only. Code: `modeId` in `AgentSessionConfig` (`packages/protocol/src/messages.ts:257`).
- **Attachment** — GitHub PR or Issue bound to an agent prompt. UI: "Attach issue or PR". Code: `AgentAttachment` (`packages/protocol/src/messages.ts:782`).
- **Attachment** — External or local context bound to an agent prompt: forge issue/change request, review context, uploaded file, text, or image. UI: "Attach issue or PR/MR". Code: `AgentAttachment` (`packages/protocol/src/messages.ts:782`).
- **Composer** — The whole prompt surface for sending work to an agent. Code: `Composer` (`packages/app/src/composer/index.tsx`). Don't call this "message input" except for the text-entry subcomponent.
- **Composer input** — The text-entry surface inside the composer. Code: `MessageInput` (`packages/app/src/composer/input/input.tsx`).
- **Composer toolbar** — The bottom control row inside the composer input. Contains agent controls, attachment button, voice controls, and stop/send controls. Code: `leftContent`, `beforeVoiceContent`, and `rightContent` slots in `MessageInput` (`packages/app/src/composer/input/input.tsx`). Forbidden: "Status bar".
- **Agent controls** — Provider, model, mode, thinking, and provider-feature controls for an agent or draft agent. Code: `AgentControls` / `DraftAgentControls` (`packages/app/src/composer/agent-controls/index.tsx`). Forbidden: "Agent status bar".
- **Composer footer** — Optional area rendered below the composer input but still inside the keyboard-shifted composer layout. Code: `Composer.footer` (`packages/app/src/composer/index.tsx`).
- **Composer track** — A contextual lane above the composer input. Specific tracks use the `<thing> track` form: **Queue track**, **Subagents track**. Code: queue track inside `Composer` (`packages/app/src/composer/index.tsx`), `SubagentsTrack` (`packages/app/src/subagents/track.tsx`).
- **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".
- **Conflict** — Two distinct senses; do NOT use the bare word in UI copy without qualifying which: (a) **stale-write conflict** on `paseo.json` ("Config changed on disk", code `stale_project_config`, `packages/app/src/screens/project-settings-screen.tsx:593`); (b) **git merge conflict** (no current UI string).

72
docs/hub.md Normal file
View File

@@ -0,0 +1,72 @@
# Paseo Hub relationship
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.
## Connection and authority
The daemon enrolls over HTTP(S), then opens and maintains a direct outbound WebSocket to the Hub.
The Hub never discovers or acquires the daemon through Paseo's relay. The relay remains an optional
encrypted path for normal Paseo clients and has no role in Hub enrollment, authentication, dispatch,
or reconnects.
The daemon persists a relationship ID and private connection credential before enrollment. The
relationship is independent of its current transport, so a future transport can replace the direct
WebSocket without pairing again. The current foundation supports one Hub relationship per daemon.
Normal authenticated daemon sessions may run the `hub.management.daemon.connect`,
`hub.management.daemon.get_status`, and `hub.management.daemon.disconnect` RPCs. Hub connections
receive only `hub.execution.*` authority, so execution credentials cannot manage the relationship.
## Session grants and execution ownership
Trusted clients and the Hub use the same `Session` implementation. The connection boundary supplies
grants: trusted clients receive `*`, while an enrolled Hub connection receives its persisted
`hub.execution.*` grant. One matcher handles exact RPC names and trailing namespace wildcards for
both inbound requests and outbound messages. A denied request returns the ordinary `rpc_error`
shape.
The Hub connection still has a narrow lifecycle boundary: it has no trusted-client hello/resume,
browser, binary, retained-session, or broadcast state. Its outbound execution events include only
agents owned by that daemon identity, so unrelated local agents remain outside the Hub surface.
Each Hub create carries an execution ID. The daemon stores that ID with the agent's relationship
owner before acknowledging creation. Duplicate or replayed creates for the same daemon and
execution resolve to the same durable agent. After a lost response, reconnect, or daemon restart,
the Hub retries `hub.execution.agent.create.request` with the same execution ID. The idempotent
response returns the existing agent and its current state; there is no separate reconciliation RPC.
Transient stream frames are not durably replayed.
Daemon restart preserves the Hub relationship and owned execution identity, but interrupts any
active turn. The daemon persists that agent as `closed`; an idempotent create retry returns the same
daemon, execution, and agent identity with that terminal state. Paseo never stores or automatically
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 may request `autoArchive`. Worktree creation and terminal auto-archive use
the shared workspace-aware lifecycle policy; Hub does not have a second launch or cleanup path.
## Disconnect and revocation
Normal socket loss reconnects the active relationship with bounded exponential backoff and jitter.
Daemon restart loads the same relationship and credential and reconnects without another enrollment
ceremony.
Hub authentication rejection or close code `4403` permanently revokes the local relationship. The
daemon deletes its credential, stops reconnecting, and retains only the relationship ID, Hub origin,
scopes, and a sanitized reason for status reporting.
`paseo hub disconnect` disables socket reconnect 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.
## Cross-repository compatibility
The consumer implementation lives in Paseo Cloud. Cloud owns its copy of the Hub wire schemas and
has no Paseo runtime or build dependency. Cross-repository end-to-end verification separately builds
a Paseo source checkout and exercises the real daemon, CLI, direct WebSocket, Cloud service, and
Postgres. That compatibility fixture is not a package dependency or fallback implementation.

91
docs/i18n.md Normal file
View File

@@ -0,0 +1,91 @@
# I18n
Paseo client UI translations live in `packages/app/src/i18n`.
## Supported Locales
- `en`
- `ar`
- `es`
- `fr`
- `ja`
- `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.
## Translation Scope
Translate client-owned UI copy: labels, buttons, empty states, confirmation text, and local status/error wrappers.
Do not translate agent output, daemon output, terminal contents, file paths, provider names, model names, command names, user-authored text, code blocks, logs, or raw protocol/server error text.
## 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`.
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.
Keep resource keys grouped by product surface, not component mechanics.
Run:
```bash
npx vitest run packages/app/src/i18n/resources.test.ts --bail=1
```
The parity test catches missing keys across English and every supported locale resource.
## Forge-Variant Copy
Strings that vary by git forge follow a two-tier rule:
- **Indeclinable tokens** — brand names ("GitHub", "GitLab"), the PR/MR initialism, number prefixes (`#`/`!`) — are interpolated into a single key (`"Refresh git and {{brand}} state"`). These tokens stay latin and uninflected in every supported locale, so one string per locale suffices. The value comes from the forge manifest via `getForgePresentation`.
- **Sentences containing the full change-request noun** ("pull request" / "merge request" inflects and takes gender/case in translation) use the i18next `context` mechanism: the base key carries the pull-request wording and an `_mr` sibling carries the merge-request wording (`pullRequest` / `pullRequest_mr`). Call sites pass `t(key, { context: getForgePresentation(forge).changeRequestContext })`; an undefined or unknown context falls back to the base key.
Keys scale per vocabulary family (PR vs MR), not per forge: a new forge picks an existing family in its manifest entry and needs zero locale edits.
## Migration Order
Client UI translation is staged so each pass can migrate complete local copy clusters and keep reviews focused.
1. App shell and shared UI chrome: common actions, headers, sheets, command center, and client-owned toast status.
2. Composer and agent workflow: composer input, agent controls, permission prompts, plan approval, and agent panel wrapper states.
3. Settings expansion: Appearance, Shortcuts, Integrations, Permissions, Diagnostics, About, Project settings, Host settings, and provider diagnostics.
4. Workspace and panels: setup/file/browser/terminal wrapper copy, file explorer local states, import-session flows, and remaining local toast/error wrapper text.
Within a migrated surface, do not leave mixed-language neighboring labels when those labels are owned by the client. Move the whole local copy cluster together.
### Progress
- Batch 2 migrated Composer and agent workflow chrome: Composer input and attachments, agent controls, stream permission prompts, agent panel wrapper states, and draft panel descriptors. Provider/model names, provider-defined option labels, agent output, and protocol/server diagnostics remain untranslated.
- Batch 3A migrated Settings Diagnostics/About, Appearance, Shortcuts, Integrations, and Desktop Permissions chrome. Host settings and Project settings remain for Batch 3B; raw runtime status/error details remain untranslated.
- Batch 3B migrated Host settings, Provider diagnostics, and Project settings chrome. Provider/model names, project/host labels, script commands, diagnostic output, file paths, and raw runtime/server error details remain untranslated.
- Batch 4A migrated workspace wrapper chrome for import sessions, file explorer, setup, browser, terminal, and file panels. File paths, URLs, commands, logs, terminal output, provider labels, and raw runtime/server errors remain untranslated.
- Batch 4B migrated workspace tab shell, workspace scripts, Git actions/diff/PR chrome, worktree archive warnings, Open-in-editor controls, and inline review controls. Branch names, PR titles/bodies, check names, workflow names, file paths, diff contents, commands, terminal output, provider labels, and raw runtime/server errors remain untranslated.
- Batch 4C migrated Sidebar project/workspace menus, hide/remove/archive confirmations, workspace rename chrome, New workspace ref picker/create flow, and Open project home tiles. Workspace/project names, branch names, PR titles, paths, provider labels, daemon output, and raw runtime/server errors remain untranslated.
- Batch 4D migrated provider/model selector chrome, provider catalog install modal, add-connection method modal, and paste-pairing-link modal. Provider/model/catalog names, provider descriptions, pairing URLs, protocol parser errors, daemon connection details, and raw runtime/server errors remain untranslated.
- Batch 4E migrated onboarding welcome chrome, direct-connection modal fields/actions/local failure guidance, QR scan permission/unavailable states, and desktop pair-device card. Pairing URLs, host/port placeholders, endpoint values, transport details, protocol parser errors, and raw runtime/server errors remain untranslated.
- Batch 4F migrated realtime voice overlay accessibility labels, rewind menu chrome and fallback toast, DiffViewer default empty state, and service URL chooser copy. Shortcut key names, raw daemon errors, diff contents, URLs, and caller-provided override labels remain untranslated.
- Batch 4G migrated the keyboard shortcuts help dialog to render section titles, row labels, and row notes through translation keys. Shortcut key names, shortcut combos, binding IDs, action IDs, and fallback registry labels remain untranslated.
- Batch 4H migrated Sessions screen chrome and AgentList local UI copy: date section headers, local status labels, fallback session titles, badges, load-more/empty states, and the archive action sheet. Agent titles, project paths, provider icons/labels, host labels, relative timestamps, and raw runtime data remain untranslated.
- Batch 4I migrated message utility chrome: image lightbox labels/errors, code and turn copy accessibility labels, dictation controls, question form fallback placeholders/actions, PlanCard fallback title, assistant image fallback errors, and todo list labels. Message bodies, plan text, todo item text, attachment labels, runtime dictation errors, and agent/tool output remain untranslated.
- Batch 4J migrated workspace tab toast/empty chrome: copy failure messages, copied labels, resume-command availability errors, reload-agent local status, host-disconnected wrapper reuse, and split-pane empty state. Agent IDs, generated resume commands, workspace paths, branch names, and raw reload errors remain untranslated.
- Batch 4K migrated sidebar/project list chrome: host picker fallback/search/title, footer actions and tooltips, Sessions row labels, mobile close label, New workspace tooltip/accessibility label, project-list empty states, and project settings host-load wrapper text. Host names, project names, workspace names, paths, branch names, and raw host errors remain untranslated.
- Batch 4L migrated picker/file/detail utility chrome: project picker states, branch switcher labels/placeholders, file pane loading/empty/fallback errors, tool-call details section/empty labels, and open-file accessibility. Directory paths, branch names, file contents, file sizes, tool details, and raw file-load errors remain untranslated.
- Batch 4M migrated hook/modal utility chrome: image attachment permissions/dialog/errors, copied toast wrappers, rename modal local validation/fallback errors, branch switcher stash/switch prompts and fallback toasts, and workspace setup local fallback errors. Copied labels supplied by callers, branch names, selected paths, raw dialog/API errors, and raw server errors remain untranslated.
- Batch 4N migrated pure view-model/policy utility chrome: import-session fallback titles/previews/empty states and the worktree setup callout. Provider labels and IDs remain runtime values interpolated into translated wrappers.
- Batch 4O migrated remaining small utility chrome: workspace route gate states/actions, compaction markers, archived-agent callout, web browser fallback, desktop quitting overlay, and image drop overlay. Host names, host status values, browser IDs, token counts, and raw route errors remain runtime values.
- Batch 4P migrated provider-selection pure view-model utility copy to direct `i18n.t(...)` calls and removed the local labels parameter path. Provider/model labels, provider IDs, and provider snapshot error messages remain runtime values.
- Batch 4Q migrated desktop update utility chrome: app update status text, update callout titles/actions, generic update errors, and install-error wrappers. Version labels, installer messages, raw update errors, release-channel data, and logs remain runtime values.
- Batch 4R migrated desktop permission utility chrome: permission status details, permission request fallback errors, empty permission statuses, and desktop notification test wrappers. Browser permission states, exception names, and raw browser API error messages remain runtime values.
- Batch 4S migrated desktop daemon settings chrome: built-in daemon status rows, daemon lifecycle toggles, logs/status modals, clipboard alerts, daemon management confirmations/errors, daemon status load errors, and desktop CLI/skills install wrapper errors. PIDs, log paths, log contents, CLI status output, version values, and raw IPC errors remain runtime values.
- Batch 4T migrated remaining attachment/autocomplete utility chrome: user and composer review attachment labels, workspace hover-card accessibility, branch stash restore prompts/toasts, agent autocomplete loading/empty/fallback error text, older-history fallback toast, draft panel labels, and agent-control fallback labels. PR/issue numbers, browser element tags, branch names, provider labels, model labels, agent prompts, command/file names, and raw server errors remain runtime values.
- Batch 4U migrated shared default utility chrome: Combobox and Autocomplete default placeholders/empty/loading labels, drag-overlay and subagent-track loading labels, sub-agent activity fallback headers, and file-preview fallback errors. Caller-provided labels, tab titles, subagent descriptions, file paths, and raw file-load errors remain runtime values.
- Batch 4V migrated Git policy action chrome to direct `i18n.t(...)` calls: commit/pull/push/sync/PR/merge/auto-merge/archive action labels, pending/success labels, and unavailable reasons. Branch/base refs, PR URLs, GitHub merge-state enum values, runtime statuses, and raw Git/GitHub errors remain runtime values.
- Batch 4W migrated remaining local wrapper states: workspace copy unavailable toasts, startup daemon-log loading/empty/load-failed text, and file-explorer workspace/host unavailable fallbacks. Workspace paths, branch names, daemon log contents/paths, checkout query details, and raw file/daemon errors remain runtime values.
- Batch 4X migrated descriptor and command chrome: Pair-device modal header, workspace setup sheet title, terminal panel fallback labels, command-center action titles, and file-pane host-disconnected fallback. Provider/catalog names, command-center search keywords, terminal runtime titles, file paths, and raw read errors remain runtime values.
- 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.

107
docs/mobile-panels.md Normal file
View File

@@ -0,0 +1,107 @@
# Mobile panels
Compact layouts have three mutually exclusive destinations:
- `agent-list` on the left
- `agent` in the center
- `file-explorer` on the right
They are one interaction, not two independent drawers. The implementation lives in
`packages/app/src/mobile-panels/`.
## Ownership
React/Zustand owns the durable intent:
```ts
interface MobilePanelSelection {
target: "agent-list" | "agent" | "file-explorer";
revision: number;
}
```
Every semantic target change increments `revision`. Repeating the current target is idempotent.
Compact panel selection is not persisted; a cold start begins at `agent`.
The UI worklet owns transient motion:
- one normalized position (`-1` left, `0` center, `1` right)
- the current motion target
- the active gesture's starting revision
- the last settled target
React also owns presentation lifecycle: whether an overlay is mounted/displayed and whether it may
receive pointer events. Worklets never own `display` or `pointerEvents`.
## Why one position
Both transforms and both backdrop opacities are derived from the same normalized position. Window
width is only a projection input. Rotation changes the projection, not the panel state.
This makes these invalid states unrepresentable:
- a panel and its backdrop disagreeing
- left and right drawers both claiming to be open
- a width-sync effect resetting an active drag
- one animation context settling a transition owned by the other
Do not add another panel translate shared value, backdrop shared value, or width synchronization
effect.
## Ordering and interruption
A gesture captures the current revision when it becomes active. Per-frame updates are accepted only
while that revision still owns the gesture.
When a React command arrives during a drag, its newer revision clears gesture ownership and starts
motion toward the new target. The older gesture's remaining updates and finish callback are ignored.
Canceled gestures return to the latest canonical target. Animation completion is accepted only when
its target and revision still match the canonical command.
Manual gesture arbitration has two phases:
1. Before activation, determine whether horizontal intent may begin.
2. After activation, stop running begin checks and let the active revision own updates.
Re-running the begin gate after activation self-cancels the gesture because an active gesture is, by
definition, no longer eligible to begin.
## Integration rules
- Callers request semantic targets through `panel-store`; they never write shared values.
- Gesture behavior comes from the four explicit hooks in `mobile-panels/gestures.ts`.
- Keep `SidebarModelProvider` outside `MobileGestureWrapper`. The provider shares sidebar derivation
across consumers, while Gesture Handler requires the wrapper's direct child to be a native `View`
so its injected `collapsable={false}` reaches Android/Fabric.
- Mobile sidebars render through `MobilePanelOverlay`; do not duplicate overlay lifecycle or motion
styles in sidebar components.
- The desktop left sidebar is retained too. App chrome owns separate mounted and visible decisions:
closing it or yielding its width marks it inactive and applies `display: none` without conditionally
removing the sidebar tree.
- Animated panel nodes use React Native static styles plus inline theme values. Do not attach
Unistyles-generated styles to those nodes; Unistyles and Reanimated patching the same Fabric node
has caused native crashes.
- The plain React wrapper owns `display: none` after settlement. This prevents a stale Fabric animated
prop commit from resurrecting a closed overlay.
- Hidden tabs and workspaces use `RetainedPanel`. It owns a non-collapsible native root, visibility,
pointer events, and the active signal consumed by `useRetainedPanelActive`.
- Panels whose gesture wrapper already owns visibility use `RetainedPanelActivity` to provide the
same active signal without adding another layout root. Persistent animations, timers, polling, and
shared clocks must subscribe to that signal and stop when their final visible consumer leaves.
- Synchronized step animations use one wall-clock-aligned source. Register a local shared value only
while its retained panel is active so hidden animated styles remain mounted without receiving clock
updates. Do not give every instance its own loop or leave hidden styles subscribed to the source.
- Retention order and render order are separate concerns. LRU metadata may change on every switch;
keyed retained roots must keep a stable sibling order. Moving large retained roots triggered Fabric
Differ failures (`addViewAt` / `removeViewAt` view reuse) on Android.
- The newly active panel must be included in the same render that changes selection. Adding it from an
effect creates a committed frame where every retained panel is hidden, which is a real blank screen.
- Do not suspend retained native subtrees with `Suspense`/`react-freeze`. Suspension changes native
ownership and can detach descendants. Keep the tree mounted, stabilize its subscriptions/selectors,
and use the retained-panel active signal to stop timers, polling, and other genuine background work.
## Tests
`packages/app/src/mobile-panels/model.test.ts` exercises command, drag, cancellation, interruption,
rapid-command, stale-completion, and width-projection sequences through the transition model. Add a
sequence there whenever ownership or ordering changes.

View File

@@ -198,6 +198,19 @@ for i in $(seq 1 "$ITERATIONS"); do
done
```
### Android audio focus interruptions
Voice mode uses the custom `expo-two-way-audio` Android module, so incoming calls and other system audio owners must be tested with emulator/system commands, not a JS-only test. To verify that voice resume handles denied audio focus without crashing:
```bash
adb shell am start -n sh.paseo/.MainActivity
# Start voice mode in an existing composer, then background Paseo with Home.
adb emu gsm call 5551234
# Foreground Paseo while the call is still ringing.
```
Expected result: Paseo does not throw `RuntimeException: Audio focus request failed`; native audio reports an interruption and voice mode stops or pauses coherently.
## Unistyles + Reanimated
### The crash

View File

@@ -9,7 +9,7 @@ Replace the OpenCode provider's per-directory `/event` stream with OpenCode's `/
## Environment
- `opencode --version`: `1.14.46`
- `which opencode`: `/Users/moboudra/.asdf/installs/nodejs/22.20.0/bin/opencode`
- `which opencode`: `opencode`
- `node --version`: `v22.20.0`
- `npm --version`: `10.9.3`

View File

@@ -70,10 +70,10 @@ Anyone who builds software:
## Current state (May 2026)
- Desktop (Electron), mobile (iOS/Android), web, CLI
- Built-in providers: Claude Code (Agent SDK), Codex (app-server), GitHub Copilot (ACP), OpenCode, Pi
- One-click ACP provider catalog: Cursor, DeepSeek TUI, Hermes, Qwen Coder, Kimi Code, and others — plus custom ACP providers
- Built-in providers: Claude Code (Agent SDK), Codex (app-server), GitHub Copilot (ACP), OpenCode, Pi, OMP
- One-click ACP provider catalog: CodeWhale, Cursor, Hermes, Qwen Coder, Kimi Code, and others — plus custom ACP providers
- Voice mode: dictate prompts or talk through problems hands-free
- MCP server exposes the daemon to other agents (create_agent, send_agent_prompt, schedules, terminals, worktrees)
- MCP server exposes the daemon to other agents (workspaces, create/detach agent, schedules, heartbeats, terminals, workspace renaming)
- Scheduled agents (cron-style triggers) via app, CLI, and MCP
- Frequent releases (multiple per week)
- Community contributions across packaging, providers, and bug fixes

View File

@@ -0,0 +1,42 @@
# Protocol Validation
The client validates inbound WebSocket messages with a zod-aot generated validator instead of runtime Zod on the hot path. Zod remains the authoring source of truth for schemas and TypeScript types.
The reason is mobile performance. A captured 353 KB provider snapshot cost about 10.9 ms and 5.9 MB allocated per message for `JSON.parse` plus Zod on Hermes. After moving provider-model normalization out of the schema so zod-aot could compile the hot subtree, the generated validator path measured about 2.5 ms and 1.2 MB allocated.
## Runtime Path
`packages/protocol/src/validation/ws-outbound.ts` is the shipped boundary. It calls the generated `WSOutboundMessageSchema.safeParse` and returns the validated data. It does not normalize, repair, or re-validate the generated result.
Generated validators preserve unknown keys where Zod object parsing strips them. The client dispatch path uses known `type` and payload fields, so this passthrough behavior is accepted for inbound messages. The wire format is unchanged.
Provider model normalization is a parser-side compatibility shim in the client consumers that need it. Newer daemons normalize at the provider registry source.
## Codegen Ownership
The protocol package owns generation.
- `packages/protocol/codegen/ws-outbound.compile.ts` is the build-time zod-aot discovery entry.
- `packages/protocol/scripts/generate-validation-aot.mjs` runs the exact-pinned compiler and applies the small local compiler patches before generation.
- `packages/protocol/scripts/watch-validation-aot.mjs` reruns generation while editing protocol sources.
- `packages/protocol/src/generated/validation/ws-outbound.aot.ts` is generated runtime code and is gitignored.
- `packages/protocol/src/validation/ws-outbound-schema-metadata.ts` is runtime schema metadata for zod-aot fallback/default references.
Generation runs from protocol-owned lifecycle hooks: `prebuild`, `pretypecheck`, `pretest`, and `watch`. Installs do not run generation: published packages consume protocol from prebuilt `dist`, and local build/typecheck/test flows generate the source file at the point it is actually needed.
## Regression Tests
zod-aot is exact-pinned and young enough that compiler patches are treated as part of this package. `packages/protocol/tests/validation/ws-outbound.test.ts` keeps small regression tests for the patched cases:
- discriminated-union branch output must propagate `.default()` fields
- current sequential item routing must accept `tool_call`-like status branches
- generated runtime imports must keep `.js` extensions for packaged Node ESM
- the generated WebSocket envelope accepts a minimal valid message and rejects a corrupted one
## Schema Purity
Message schemas are structural declarations. Do not put `.transform()`, `.catch()`, or `.preprocess()` on WebSocket message schemas. If parsed data needs normalization, put it in an explicit consumer or post-validation pass.
Use `z.discriminatedUnion()` when every branch has a shared literal tag. Plain `z.union()` is acceptable only when there is no shared literal discriminator or when a generated-code regression test proves that specific shape is miscompiled.
Defaults are allowed only on primitive leaves. Do not place `.default()` on large arrays, item schemas, or big containers in inbound message schemas.

View File

@@ -10,41 +10,82 @@ Extend `ACPAgentClient` from `packages/server/src/server/agent/providers/acp-age
The only built-in ACP provider today is `copilot` (`copilot-acp-agent.ts`). `GenericACPAgentClient` (`generic-acp-agent.ts`) is also ACP-based but is used for user-defined custom providers configured via `extends: "acp"` overrides — see [docs/custom-providers.md](custom-providers.md).
Copilot custom agents are exposed through ACP session config, not the slash-command list. When custom agents are available, Copilot returns a select config option with `id: "agent"` and `category: "_agent"`; Paseo maps that to the `agent` provider feature. Copilot uses the agent display name as the option value, and the blank value means the default Copilot agent.
### Direct
Implement the `AgentClient` and `AgentSession` interfaces from `agent-sdk-types.ts` yourself. This gives full control but requires you to handle process management, streaming, permissions, and session persistence from scratch.
Existing direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`codex-app-server-agent.ts`), `opencode` (`opencode-agent.ts`), `pi` (`providers/pi/agent.ts`). The dev-only `mock` provider (`mock-load-test-agent.ts`) is also direct.
Existing direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`codex-app-server-agent.ts`), `opencode` (`opencode-agent.ts`), `pi` (`providers/pi/agent.ts`), and `omp` (`providers/omp/agent.ts`). The dev-only `mock` provider (`mock-load-test-agent.ts`) is also direct.
Claude first-party model metadata lives in `packages/server/src/server/agent/providers/claude/model-manifest.ts`. When adding or updating a Claude model, update that manifest only; the model picker thinking options and Claude-specific feature gates are derived from the manifest. Do not add model-specific Claude capability lists in feature code.
Paseo tools are not implemented as MCP tools internally. They live in a shared tool catalog under `packages/server/src/server/agent/tools/`; MCP is only the fallback adapter. A provider that can register runtime tools directly should set `supportsNativePaseoTools: true` and consume `launchContext.paseoTools` in `createSession`/`resumeSession`. When native tools are present, `AgentManager` strips the internal Paseo MCP server from the provider launch config so the provider does not receive the same tools twice. Providers that only know MCP should keep `supportsMcpServers: true` and let the daemon inject `/mcp/agents`.
Pi is a process-backed provider. Paseo requires the user to have the `pi` binary installed and talks to it through `pi --mode rpc`; the server package does not embed Pi's SDK/runtime packages.
Paseo's per-agent and daemon-wide system prompts are passed to Pi with `--append-system-prompt`, so Pi keeps its default coding prompt while receiving Paseo's additional instructions.
Pi MCP support depends on the open-source `pi-mcp-adapter` extension being loaded for the agent cwd. Probe with Pi RPC `get_commands`; the adapter registers an extension command named `mcp` (often with `sourceInfo.source` containing `pi-mcp-adapter`). When Paseo injects MCP servers into Pi, write a per-agent MCP config and pass it with `--mcp-config` instead of modifying user or project MCP files. For local HTTP servers such as Paseo's own `/mcp/agents` endpoint, explicitly disable adapter OAuth (`auth: false`, `oauth: false`) in the generated config.
Pi model records expose input capabilities through `model.input`. Only send raw RPC `images` when the current model explicitly includes `"image"` in that list. Text-only Pi/OMP models reject image content and persist the rejected image in JSONL history, so image prompts for those models must be materialized to a local file and passed as a text path hint instead.
Pi MCP support depends on the open-source `pi-mcp-adapter` extension being loaded for the agent cwd. Probe with Pi RPC `get_commands`; the adapter registers an extension command named `mcp` (often with `sourceInfo.source` containing `pi-mcp-adapter`). When Paseo injects MCP servers into Pi, write a per-agent MCP config and pass it with `--mcp-config` instead of modifying user or project MCP files. Because that flag replaces the Pi global config layer, preserve the existing `<Pi agent dir>/mcp.json` in the generated file before overlaying injected servers. For local HTTP servers such as Paseo's own `/mcp/agents` endpoint, explicitly disable adapter OAuth (`auth: false`, `oauth: false`) in the generated config.
Pi import discovery reads Pi's persisted JSONL session files because Pi RPC does not expose a recent-session listing command. Resume and full history hydration still go through `pi --mode rpc` using the session file as `nativeHandle`.
OMP is a first-class built-in provider, disabled by default. Its launch contract, typed runtime, agent/session behavior, history, permissions, imports, and test fake live under `providers/omp/`; only the provider-neutral JSONL child-process transport is shared with Pi. It launches `omp --mode rpc-ui`, uses OMP's `get_available_commands` RPC for slash-command discovery, bridges OMP `rpc-ui` approval dialogs into Paseo permissions, and imports terminal-started sessions from `~/.omp/agent/sessions` when enabled.
OMP supports native Paseo host tools. The adapter registers the caller-scoped Paseo tool catalog directly with OMP, so `create_agent`, `send_agent_prompt`, `wait_for_agent`, and related tools do not need the internal MCP fallback. OMP's provider-managed task subagents are surfaced as Paseo subagents through `child_session` imports; the parent keeps the subagents track while the child runtime stays owned by OMP. Custom OMP profiles should extend `omp`; other Pi-compatible forks can still extend `pi`, override `command`, and set `params.sessionDir` to their JSONL session directory.
Pi RPC extension UI dialog requests (`select`, `input`, `editor`, `confirm`) are bridged into Paseo question permissions and answered with `extension_ui_response`. Pi extensions such as `ask_user` may chain dialogs: for example, a `select` can be followed by an optional-comment `input`. When an `ask_user` tool call declares `allowComment: true`, Paseo presents the selection and optional comment as one question permission, answers Pi's initial `select` immediately, then auto-answers the follow-up optional `input` with the comment the user already supplied (or an empty string). Preserve placeholders and optional/skip semantics for standalone optional inputs so the app can still distinguish "skip this optional input" from "cancel the whole dialog." Fire-and-forget extension UI requests such as notifications are intentionally ignored by the provider adapter unless Paseo grows first-class UI for them.
OpenCode MCP injection is dynamic and session-scoped. Call OpenCode's `mcp.add` endpoint with the MCP server config and do not follow it with `mcp.connect`; `connect` only toggles MCP servers already present in OpenCode's own config. New OpenCode versions return `McpServerNotFoundError`/404 for `connect` after a dynamic add because the server is not config-backed, while older versions silently swallowed the same missing-config path.
OpenCode owns user message IDs. Do not pass Paseo-generated IDs to OpenCode prompt APIs; let OpenCode create `msg*` IDs and record the user timeline item from the `message.updated` event.
Draft metadata lookups should avoid creating provider sessions when the upstream provider has top-level APIs for that metadata. Prefer `AgentClient.listModels`, `listModes`, `listCommands`, or `listFeatures` over creating a scratch `AgentSession`; scratch sessions can show up as empty native sessions in provider import/history UIs.
Every provider adapter owns its canonical user-message timeline rows. When a foreground prompt is accepted, the adapter must emit exactly one `user_message` timeline item for that submitted prompt, using the same message ID it gives to or receives from the provider runtime. Optimistic client messages are UI-only and provider transcript echoes are optional; neither is allowed to be the only source of truth. If the provider later echoes the same submitted user message, dedupe it only within the active turn. Prefer provider-visible message IDs, but ACP runtimes may omit that ID or replace it with a provider-owned one; in that case suppress only echo chunks whose accumulated text is a prefix of the active submitted prompt. Do not perform global transcript text dedupe.
Draft metadata lookups should avoid creating provider sessions when the upstream provider has top-level APIs for that metadata. Prefer `AgentClient.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 feature and command listing must use the explicit draft model only; if no model is selected yet, return no metadata instead of resolving a default model through catalog discovery.
Provider session import has its own contract. The picker calls `listImportableSessions` and receives rows only: provider handle, cwd, title, prompt previews, and last activity. Import calls `importSession({ providerHandleId, cwd })` for the selected row and must not call listing again. The provider returns the resumed session, storage config, persistence handle, and hydrated timeline for that one native session; `AgentManager.importProviderSession` seeds the daemon timeline and publishes the Paseo agent only after it is ready.
## Provider Helper Processes
Provider-owned helper processes that can outlive an individual agent session must be recorded in the daemon's managed-process registry. Store provider/kind metadata, the PID, launch command/args, and process identity captured from the platform process table. Remove the record on normal exit or shutdown.
If a helper process has a readiness phase, the provider's lifecycle model must own the process immediately after `spawn`, before readiness succeeds. Startup timeout, startup exit, and daemon shutdown must all clean up through that owned generation. Do not keep a spawned helper only inside a readiness promise; that creates a live process outside the manager/reaper contract.
Daemon bootstrap reconciles that ledger in the background, without blocking startup: dead PIDs are deleted, PID identity mismatches are deleted without killing anything, only positively matched Paseo-owned leftovers are terminated, and a record whose process cannot be inspected is left in place for the next reconcile rather than deleted. Do not add broad process-name sweepers for provider cleanup; cleanup starts from records Paseo previously wrote.
---
## Provider Snapshot Refresh Contract
The daemon keeps provider snapshots per resolved working directory. Missing or blank cwd resolves to the user's home directory. Workspace selectors and old model/mode list requests should pass the cwd that will launch the provider so providers with project-specific models or modes are probed in the right context. Settings/provider management intentionally uses the home-directory snapshot.
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.
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.
Settings refresh is the user-facing "forget stale provider knowledge everywhere" action. A settings refresh clears provider snapshot caches and in-flight loads across all cwd scopes, then immediately refreshes only the home-directory snapshot with `force: true`. Workspace snapshots are re-probed lazily on the next scoped read; do not fan out a settings refresh across every known workspace.
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.
Boundary tests should assert observable behavior: cold reads may call provider availability/model/mode discovery for that cwd; warm reads and registry replacement must not; explicit workspace refreshes affect only one cwd; settings refresh wipes all scopes but immediately refreshes only home.
Boundary tests should assert observable behavior: cold reads may call provider availability/model/mode discovery for that scope; warm reads and registry replacement must not; explicit workspace refreshes affect only one cwd; settings refresh wipes all scopes but immediately refreshes only global.
---
## Provider Usage Fetchers
Provider plan usage is fetch-on-demand, not a daemon push subscription. The app calls `provider.usage.list.request` through React Query when the usage tooltip or Host Usage settings screen is shown, and the daemon returns the normalized `ProviderUsage` list directly.
To add plan usage for a provider, add `packages/server/src/services/quota-fetcher/providers/<provider>.ts` and register it in `packages/server/src/services/quota-fetcher/manifest.ts`. The provider file exports only its fetcher class; provider auth, endpoint constants, API schemas, and normalization helpers stay private in that file. A fetcher owns provider auth/API parsing and returns the generic shape:
- `providerId`, `displayName`, `status`, and optional `planLabel`
- any number of `windows` such as Session, Weekly, or Biweekly
- optional `balances` for credits, USD, requests, or tokens
- optional `details` for provider-specific rows
Keep the protocol shape provider-agnostic. Do not add provider-specific renderers for new limit windows; labels and generic bars should carry the UI. API responses should be parsed and normalized with Zod inside the fetcher, while the protocol boundary stays strict so old/new client compatibility is explicit.
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.
---
@@ -260,7 +301,7 @@ case "my-provider":
);
```
Add to the `allProviders` array (current built-ins are `claude`, `codex`, `copilot`, `opencode`, `pi`):
Add to the `allProviders` array (current built-ins are `claude`, `codex`, `copilot`, `opencode`, `pi`, `omp`):
```ts
export const allProviders: AgentProvider[] = [
@@ -307,11 +348,16 @@ interface AgentClient {
overrides?: Partial<AgentSessionConfig>,
launchContext?: AgentLaunchContext,
): Promise<AgentSession>;
listModels(options: ListModelsOptions): Promise<AgentModelDefinition[]>;
fetchCatalog(options: FetchCatalogOptions): Promise<ProviderCatalog>;
isAvailable(): Promise<boolean>;
// Optional:
listModes?(options: ListModesOptions): Promise<AgentMode[]>;
listPersistedAgents?(options?: ListPersistedAgentsOptions): Promise<PersistedAgentDescriptor[]>;
listImportableSessions(
options?: ListImportableSessionsOptions,
): Promise<ImportableProviderSession[]>;
importSession(
input: ImportProviderSessionInput,
context: ImportProviderSessionContext,
): Promise<ImportedProviderSession>;
getDiagnostic?(): Promise<{ diagnostic: string }>;
}
```
@@ -331,7 +377,7 @@ interface AgentSession {
getRuntimeInfo(): Promise<AgentRuntimeInfo>;
getAvailableModes(): Promise<AgentMode[]>;
getCurrentMode(): Promise<string | null>;
setMode(modeId: string): Promise<void>;
setMode(modeId: string): Promise<void | AgentProviderNotice>;
getPendingPermissions(): AgentPermissionRequest[];
respondToPermission(
requestId: string,
@@ -343,7 +389,7 @@ interface AgentSession {
// Optional:
listCommands?(): Promise<AgentSlashCommand[]>;
setModel?(modelId: string | null): Promise<void>;
setThinkingOption?(thinkingOptionId: string | null): Promise<void>;
setThinkingOption?(thinkingOptionId: string | null): Promise<void | AgentProviderNotice>;
setFeature?(featureId: string, value: unknown): Promise<void>;
tryHandleOutOfBand?(prompt: AgentPromptInput): {
run(ctx: { emit: (event: AgentStreamEvent) => void }): Promise<void>;
@@ -351,6 +397,8 @@ interface AgentSession {
}
```
`setMode` and `setThinkingOption` may return an `AgentProviderNotice` when the provider knows the change needs user-facing context. For example, providers that stage changes until the next turn should return an `info` notice while a turn is already running. The app renders the notice generically as a toast; provider-specific lifecycle behavior stays in the provider implementation.
### Steps
1. Create `packages/server/src/server/agent/providers/{name}-agent.ts` implementing both interfaces

View File

@@ -13,6 +13,15 @@ Each domain becomes a controller class in its own file with the **exact** contra
Session shrinks to a connection/dispatch shell: it keeps `handleMessage`, the `??` chain (1739-1751), `emit`/`emitBinary`, `sessionLogger`, connection identity, inflight metrics, lifecycle intents, and the **ordered** `cleanup()`. Each `dispatchXMessage` collapses to `return this.xController.dispatch(msg)`.
## Progress (shipped — diverged from the original filenames)
The first carves shipped as **deep modules with a narrow Host seam**, not the `dispatch(msg)`-owned-set controllers sketched below: `session.ts` keeps each `dispatchXMessage` switch and delegates per case to the subsystem. Home convention that emerged: session subsystems live at **`session/<domain>/`**, with `session.ts` as the orchestrator shell.
- **#1640 — VoiceSession** (`session/voice/voice-session.ts`, seam `VoiceSessionHost`): the STT/TTS/dictation/turn-detection subsystem. _(Originally landed at `server/voice/`; relocated under `session/` so all session subsystems share one home.)_
- **#1644 — CheckoutSession, read side** (`session/checkout/checkout-session.ts`, seam `CheckoutSessionHost`, port `CheckoutDiffSubscriber`): status, branch validate/suggest, diff subscribe/unsubscribe, manual refresh. The workspace-git observer already delegates `emitStatusUpdate`/`scheduleDiffRefresh` to it.
**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).
@@ -117,7 +126,7 @@ In-place, separately reviewable. Split the `audio_output` TTS-debug branch out o
## Slice 7 — VoiceSessionController (XL)
**Move:** voice handlers + ~25 voice fields + the TTS-debug hook (Slice 6) + `voiceModeAgentId`/`isVoiceMode` + the `shouldAutoAllowVoicePermission` predicate (Slice 3) → `packages/server/src/server/voice/voice-session-controller.ts`. Carve voice types out of `dispatchVoiceAndControlMessage`, leaving infra (restart/shutdown/heartbeat/ping/abort) on the shell.
**Move:** voice handlers + ~25 voice fields + the TTS-debug hook (Slice 6) + `voiceModeAgentId`/`isVoiceMode` + the `shouldAutoAllowVoicePermission` predicate (Slice 3) → the existing `packages/server/src/server/session/voice/voice-session.ts` (see Progress above). Carve voice types out of `dispatchVoiceAndControlMessage`, leaving infra (restart/shutdown/heartbeat/ping/abort) on the shell.
**SessionContext surface:** pure `emit`, `emitBinary`, `hasBinaryChannel`, `sessionLogger`/`sessionId`/`paseoHome`, `getSpeechReadiness`, agent-control port `{ loadAgent, reloadWithSystemPrompt, interruptIfRunning, isRunning, sendSpokenText, buildAgentPrompt }`, `getSignal`/`abortCurrent` (Slice 6).

View File

@@ -9,6 +9,11 @@ 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
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
@@ -24,29 +29,56 @@ Rules that apply to both steps:
- No code changes bundled into the changelog commit or the release commit. Code shims live in their own commit, reviewed on their own merits.
- A sanity-check finding is information, not a directive. The agent surfaces it; the user decides.
- Invoking a release skill is intent to start the flow, not blanket authorization to publish.
- If the user asks for a release preview, show the prospective changelog/release contents and answer questions, but do not commit, tag, publish, or run release commands until they explicitly authorize the release.
## Two paths
There are two supported ways to ship from `main`:
1. **Direct stable release**: you are ready to ship the current `main` commit to everyone immediately.
2. **Beta flow**: silent release candidates. Betas don't touch the changelog, don't move the website, and don't publish npm or production mobile builds.
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.
## Standard release (patch)
## Release version decision
Before running any stable patch release command:
Every fresh release starts by classifying the full previous-stable-to-`HEAD`
diff. The highest-impact change determines the version:
- **Minor** — a user would experience the release as a significant upgrade. This
includes substantial new workflows, providers, forges, platforms, integrations,
or meaningful expansions of existing capabilities. Foundational internal work
also qualifies when it materially changes reliability, performance,
compatibility, deployment, or operation; diff size alone does not.
- **Patch** — fixes, polish, small enhancements, and reliability or performance
improvements within existing capabilities. Follow-up corrections to a minor
release are patches.
The release agent selects patch or minor during preparation and presents the
target version with the changelog for approval. Agents never select a major
version autonomously. A major release requires an explicit user instruction and
approval; Paseo remains on major version zero until that deliberate decision.
Version bumps are never used to retry a failed build. Retry the existing version
as described in **Fixing a failed release build**.
## Standard release (stable)
Before running any stable release command:
- Make sure the intended release commit is already committed to `main` and the working tree is clean.
- **Run `npm run format`, `npm run lint`, and `npm run typecheck` and commit any resulting changes BEFORE you start any `release:*` command.** `release:check` runs `npm install --workspaces --include-workspace-root` as part of `release:prepare`, which can mutate `package-lock.json` (e.g. churning `"dev": true` markers on optional deps). The next step, `version:all:*`, runs `npm version` which aborts when the working tree is dirty. If this happens mid-flight you have to commit the lockfile churn before retrying — and the pre-commit format hook will reject a lockfile-only commit because oxfmt internally skips `package-lock.json` while lefthook's glob still matches it. Avoid the whole mess by running format/lint/typecheck first, then `release:prepare` once on its own to absorb any lockfile churn into a normal commit, then start the release.
- Do not use `npm run release:patch` as a substitute for checking whether the current commit is actually ready.
- Do not use a release command as a substitute for checking whether the current commit is actually ready.
```bash
# Run exactly one, matching the approved decision:
npm run release:patch
npm run release:minor
```
This bumps the version across all workspaces, runs checks, publishes to npm, and pushes the branch + tag. The tag push triggers `Desktop Release`, `Android APK Release`, and `Release Notes Sync` on GitHub Actions. EAS picks up the same tag via the EAS GitHub app and starts the iOS + Android store builds in parallel (see "Mobile builds (EAS)" below) — there is no `release-mobile.yml` in this repo.
This bumps the version across all workspaces, runs checks, publishes to npm, and pushes the branch + tag. The tag push triggers `Desktop Release`, `Android APK Release`, `Docker`, and `Release Notes Sync` on GitHub Actions. 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.
**Releases are always patch.** "Release paseo", "release stable", "ship stable", and similar always mean a patch bump from the previous stable. Never bump minor or major to trigger a build, ever — minor and major bumps are reserved for genuinely larger product cuts and require an explicit user instruction with the word "minor" or "major". If you find yourself reaching for `release:minor` to retrigger a failed build, you are doing the wrong thing — push a retry tag instead (see "Fixing a failed release build" below).
The Docker workflow builds images from the checked-out source tree on pull requests and on `main` as non-publishing checks. Stable `vX.Y.Z` tag pushes publish `ghcr.io/getpaseo/paseo:X.Y.Z` and `ghcr.io/getpaseo/paseo:latest`; beta `vX.Y.Z-beta.N` tag pushes publish only `ghcr.io/getpaseo/paseo:X.Y.Z-beta.N` and never move `latest`.
Relay deployment is manual-only while `relay.paseo.sh` bridges traffic to the Fly deployment. Releases and pushes to `main` do not deploy the Cloudflare relay worker. Deploy it explicitly with `gh workflow run deploy-relay.yml` only when the production bridge should change.
**Stable means stable.** If the user says "stable" or "ship stable", do not ask whether they want a beta first. They picked stable; treat it as a direct stable release. Only run the beta flow when the user explicitly says "beta".
@@ -55,7 +87,9 @@ This bumps the version across all workspaces, runs checks, publishes to npm, and
```bash
npm run typecheck # Verify the exact commit you intend to release
npm run release:check # Typecheck, build, dry-run pack
npm run version:all:patch # Bump version, create commit + tag
# Run exactly one approved version command:
npm run version:all:patch
npm run version:all:minor
npm run release:publish # Publish to npm
npm run release:push # Push HEAD + tag (triggers CI workflows)
```
@@ -63,18 +97,20 @@ npm run release:push # Push HEAD + tag (triggers CI workflows)
## Beta flow
```bash
npm run release:beta:patch # Bump to X.Y.Z-beta.1, push commit + tag
npm run release:beta:patch # Start the next patch beta line
npm run release:beta:minor # Start the next minor beta line
# ... test desktop and APK prerelease assets from GitHub Releases ...
npm run release:beta:next # Optional: cut X.Y.Z-beta.2, beta.3, ...
npm run release:promote # Promote X.Y.Z-beta.N to stable X.Y.Z
```
- Beta tags are published GitHub prereleases like `v0.1.41-beta.1`
- Betas publish desktop assets and APKs for testing, but they do not publish npm packages and do not trigger the production web/mobile release flows
- 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
- `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 don't touch `CHANGELOG.md`.** Beta GitHub releases ship with empty notes — that's intentional. The changelog entry is written once, at promotion time, covering the full stable-to-stable diff. The release-notes sync script skips betas cleanly because no matching section exists.
- **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.
Use the beta path when you need to:
@@ -85,7 +121,7 @@ Use the beta path when you need to:
## Staged rollout (stable channel)
Stable desktop releases go out via a linear time-based rollout: 0% admitted when the updater manifests appear, 100% admitted 36 hours later, linear ramp in between. Beta releases bypass the rollout entirely — beta users always receive updates immediately.
Stable desktop releases go out via a linear time-based rollout for automatic update checks: 0% admitted when the updater manifests appear, 100% admitted 36 hours later, linear ramp in between. Manual checks bypass the rollout so a user can install immediately when they click **Check**. Beta releases bypass the rollout entirely — beta users always receive updates immediately.
The rollout is driven by a `rolloutHours` field stamped into the GitHub Release manifests (`latest-mac.yml`, `latest-linux.yml`, `latest.yml`) by the `finalize-rollout` job in `desktop-release.yml`.
@@ -98,7 +134,7 @@ Updater clients only discover a release through those `.yml` manifests, so there
### Default behavior
`npm run release:patch` → tag push → 36h ramp. No extra action needed.
`npm run release:patch` or `npm run release:minor` → tag push → 36h ramp. No extra action needed.
The `rollout_hours` input on `desktop-release.yml` is **only read on `workflow_dispatch`** — tag-push runs always default to 36. To get any other rollout duration on a fresh release, use the post-publish flip below.
@@ -118,7 +154,7 @@ gh workflow run desktop-rollout.yml \
**Why this is gap-free:** `desktop-release.yml`'s `finalize-rollout` job and `desktop-rollout.yml` share the concurrency group `desktop-rollout-<tag>`. Dispatching `desktop-rollout.yml` while the tag-push pipeline is still running queues it safely behind `finalize-rollout`. The first public manifests already carry `rolloutHours=36`, then `desktop-rollout.yml` flips them to `rolloutHours=0` shortly afterward. The renderer polls every 30 minutes, so active stable users pick up the new manifest on their next check.
Run the dispatch right after `release:patch` returns. Don't wait for the tag-push CI to finish.
Run the dispatch right after `release:patch` or `release:minor` returns. Don't wait for the tag-push CI to finish.
### Adjusting an already-published release
@@ -156,20 +192,20 @@ gh workflow run desktop-release.yml \
-f rollout_hours=6
```
This does **not** apply to fresh releases cut via `npm run release:patch` — that path always tag-pushes and stamps 36. For a fresh release with a custom ramp, cut normally and then dispatch `desktop-rollout.yml` (same pattern as the instant-admit flow above, with your chosen `rollout_hours`).
This does **not** apply to fresh releases cut via `npm run release:patch` or `npm run release:minor` — those paths always tag-push and stamp 36. For a fresh release with a custom ramp, cut normally and then dispatch `desktop-rollout.yml` (same pattern as the instant-admit flow above, with your chosen `rollout_hours`).
### Releasing during an active rollout
If you ship N+1 while N is still ramping, N+1 starts a fresh rollout from its own publish timestamp. N's rollout effectively ends — the newer manifest supersedes it.
If you ship N+1 while N is still ramping, N+1 starts a fresh rollout from its own publish timestamp. N's rollout effectively ends — the newer manifest supersedes it. Rollout-aware clients revalidate the manifest for up to five seconds before installing a downloaded update on quit. If N+1 has replaced N but the client is not admitted to N+1 yet, it skips the downloaded N and waits rather than installing two updates in succession. If revalidation times out, the app exits without installing the cached update.
If N+1 is a hotfix for a bug in N, dispatch `desktop-rollout.yml -f tag=v0.1.<N+1> -f rollout_hours=0` after N+1 publishes so the users who already got N reach the fix fast.
### Limitations
- **No pause / kill switch.** Once a stable user is admitted, they will install the update on next quit (`autoInstallOnAppQuit = true`). To stop new admissions, ship a superseding release. To "recall" already-admitted users, ship a hotfix `+1` patch.
- **No pause / kill switch.** To stop new admissions, ship a superseding release. Clients revalidate on quit and will not install the superseded download, but a client that already completed installation cannot be recalled; ship a hotfix `+1` patch.
- **No rollback.** `allowDowngrade = false`. Bad release = ship a hotfix.
- **Bootstrap caveat.** Clients running a build older than the rollout feature ignore `rolloutHours` and admit immediately. Rollout protection only applies to clients running the rollout-aware version or later.
- **Up to ~30 min admission latency.** Renderer polls every 30 minutes, so a stable user may take up to that long to be evaluated against the rollout window.
- **Up to ~30 min automatic admission latency.** Renderer polls every 30 minutes, so a stable user may take up to that long to be evaluated against the rollout window. Clicking **Check** is manual and bypasses rollout admission.
## Mobile builds (EAS)
@@ -179,6 +215,8 @@ 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.
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.
### Watching mobile builds from the terminal
@@ -191,6 +229,9 @@ cd packages/app
# Recent builds (newest first). Pipe to jq for status only.
npx eas build:list --limit 8 --non-interactive --json | jq '.[] | {platform, status, appVersion, gitCommitHash}'
# Recent EAS workflow runs. This is the source of truth for submit/review jobs.
npx eas workflow:runs --json | jq '.[] | {status, workflowName, trigger, gitCommitHash, startedAt, finishedAt}'
# Filter by platform.
npx eas build:list --platform ios --limit 5 --non-interactive --json
npx eas build:list --platform android --limit 5 --non-interactive --json
@@ -198,35 +239,52 @@ npx eas build:list --platform android --limit 5 --non-interactive --json
# Inspect a specific build.
npx eas build:view <build-id>
# Inspect the full release workflow, including submit_ios, submit_android,
# and submit_ios_for_review.
npx eas workflow:view <workflow-run-id> --json
# Read failed submit/review job logs.
npx eas workflow:logs <workflow-job-id> --all-steps --non-interactive
# Stream logs for a build.
npx eas build:view <build-id> --json | jq '.logFiles[]'
```
A build's `gitCommitHash` must match the release tag commit. `status` walks through `NEW``IN_QUEUE``IN_PROGRESS``FINISHED` (or `ERRORED`/`CANCELED`).
A build's `gitCommitHash` must match the release tag commit. `status` walks through `NEW``IN_QUEUE``IN_PROGRESS``FINISHED` (or `ERRORED`/`CANCELED`). The EAS workflow run's `gitCommitHash` and `trigger` must also match the release tag.
Once a build is `FINISHED`, EAS auto-submits it to the store: Android via the `submit` block in `eas.json` (EAS-managed Play Console credentials), iOS via the Fastlane `submit_review` lane (uploads to TestFlight, then submits for App Store review). To confirm the submission landed, run `npx eas build:view <build-id>` and open the `Logs` URL it prints — the build's Expo dashboard page has a Submissions section listing each attempt with its store response. App Store Connect (TestFlight tab → ready for review) and the Play Console (Internal testing / Production tracks) are the final ground truth.
Once a build is `FINISHED`, EAS still has release-critical work to do: Android must submit to the Play Store, and iOS must upload to TestFlight **and** submit the build for App Store review. The release is not done until all platforms are on their way through the stores.
For the `Release Mobile` EAS workflow, these jobs must pass:
- `build_ios` — iOS binary built
- `submit_ios` — iOS binary uploaded to App Store Connect/TestFlight
- `submit_ios_for_review` — iOS build submitted for App Store review via Fastlane
- `build_android` — Android store binary built
- `submit_android` — Android binary submitted to the Play Store
Do not treat `build_ios: SUCCESS` or `submit_ios: SUCCESS` as a completed iOS release. `submit_ios_for_review: FAILURE` means the iOS release is blocked even if the build is visible in TestFlight.
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
The user rarely opens the Expo dashboard. A failed EAS build can sit silently until users complain about a stale version. After every stable release, set up a long-delay babysit that re-checks both EAS builds and GitHub Actions for the release tag. If anything is `ERRORED` or `FAILED`, surface it immediately. If everything is `FINISHED`/`SUCCESS`, confirm and stop.
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.
**Use a heartbeat schedule, never a new-agent schedule.** Babysitting fires back into the current conversation as a wake-up prompt `target: "self"` in `mcp__paseo__create_schedule`. Never use `target: "new-agent"`. A new agent spawns a fresh conversation the user has to find and read; a 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 `new-agent` for a release babysit, you are about to ship a status report into a void.
**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.
Pattern:
```jsonc
// mcp__paseo__create_schedule arguments
// mcp__paseo__create_heartbeat arguments
{
"name": "vX.Y.Z release babysit heartbeat",
"every": "15m",
"cron": "*/15 * * * *",
"maxRuns": 8, // covers ~2h of build + store-submission window
"target": "self", // heartbeat, NOT "new-agent"
"cwd": "/path/to/paseo",
"prompt": "Heartbeat: check vX.Y.Z release builds. Run gh run list + eas build:list, report concisely; flag any ERRORED/FAILED/CANCELED.",
"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.",
}
```
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 that errors 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 everything is green so the remaining runs do not generate noise.
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.
## Release notes on GitHub
@@ -236,7 +294,8 @@ The GitHub Release body is populated automatically by the `Release Notes Sync` w
- The website download page points to GitHub's latest published **stable** release.
- Published beta prereleases are public on GitHub Releases, but they do **not** become the website download target.
- The website only moves when you publish the final stable release tag like `v0.1.41`.
- The 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 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
@@ -245,9 +304,30 @@ The GitHub Release body is populated automatically by the `Release Notes Sync` w
**Do not rely on `workflow_dispatch` for tagged code fixes.** The `workflow_dispatch` trigger runs the workflow file from the default branch but checks out the code at the tag ref (`ref: ${{ inputs.tag }}`). That means fixes committed to `main` won't change the tagged source tree being built. `workflow_dispatch` only helps when the fix lives in the workflow file itself.
To retry a failed workflow, **always push a retry tag** on the commit you want to build. Reusing the same tag name is expected: move it with `git tag -f ...` and push it with `--force` so the workflow rebuilds the commit you actually want.
For Docker-only retries, **do not push or force-push a `v*` release tag**.
`v*` tag pushes rebuild desktop assets, the Android APK, Docker, release notes,
and EAS mobile release builds. Use the Docker workflow dispatch instead:
Prefer a tag push over `workflow_dispatch` whenever you are rebuilding release code or release assets.
```bash
gh workflow run docker.yml \
--ref main \
-f paseo_version=X.Y.Z-beta.N \
-f publish=true
```
This replaces `ghcr.io/getpaseo/paseo:X.Y.Z-beta.N` in place without touching
desktop, APK, or EAS release builders. The Docker exception is safe because the
dispatch runs from `--ref main` and uses the explicit `paseo_version`; it does
not check out or move the `v*` release tag.
To retry a failed non-Docker release workflow, push a retry tag on the commit
you want to build. Reusing the same tag name is expected: move it with
`git tag -f ...` and push it with `--force` so the workflow rebuilds the commit
you actually want.
Prefer a tag push over `workflow_dispatch` when rebuilding desktop or APK
release assets. Prefer Docker workflow dispatch when rebuilding only the Docker
image.
The retry tag patterns below still work and remain the supported way to rebuild specific release targets:
@@ -280,6 +360,7 @@ This ensures the checkout ref matches the actual code on `main` with the fix inc
- `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
- If `release:publish:beta` partially fails, re-run it — npm skips already-published versions and keeps prereleases off `latest` because every publish uses `--tag beta`
- The website uses GitHub's latest published release API for download links, so published beta prereleases do not replace the stable download target.
## Changelog format
@@ -288,20 +369,24 @@ Release notes depend on the changelog heading format. The heading **must** be st
```
## X.Y.Z - YYYY-MM-DD
## X.Y.Z-beta.N - YYYY-MM-DD
```
No prefix (`v`), no extra text. The parser matches the first `## X.Y.Z` line to extract the version. A malformed heading will break download links on the homepage.
No prefix (`v`), no extra text. `Release Notes Sync` matches the `## X.Y.Z` (or `## X.Y.Z-beta.N`) line for the pushed tag to extract the version. A malformed heading breaks the release-notes sync for that tag.
## Changelog policy
- `CHANGELOG.md` only lists stable releases. Betas are silent.
- The changelog entry is authored once, at stable promotion time, with the date set to the promotion day.
- It covers the full diff from the previous stable tag, regardless of how many betas were cut in between.
- `CHANGELOG.md` includes stable releases and the current beta line.
- 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.
## Changelog ownership
- **Only Claude should write changelog entries.**
- If you are Codex and a stable release needs a changelog entry, launch a Claude agent with Paseo to draft it, then review and commit the result.
- **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.
## Changelog voice
@@ -380,42 +465,52 @@ Entries within each section (Added, Improved, Fixed) are ordered by user impact:
## Pre-release sanity check
Before cutting a **stable** release, run a Codex review of 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.
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.
Load the `paseo` skill and launch a **Codex 5.4** agent with a prompt like:
Review the diff between the latest release tag and `HEAD`. Focus on:
> Review the diff between the latest release tag and HEAD. 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.
>
> Diff: `git diff <latest-release-tag>..HEAD`
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.
The agent's job is a deep sanity check, not a full code review. If it flags anything, investigate before proceeding.
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.
## Changelog scope
The changelog covers **stable-to-stable**. Betas are not represented. When you promote, draft the entry from the diff between the previous stable tag and `HEAD`, ignoring beta tag boundaries — they're just checkpoints along the way.
The changelog always covers **previous-stable-to-`HEAD`**, beta and stable alike:
- **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.
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.
## Completion checklist
### Beta release
- [ ] Working tree is clean and the intended commit is on `main`
- [ ] `npm run release:beta:patch` (or `:next`) completes successfully
- [ ] 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
- [ ] `npm run release:beta:patch`, `npm run release:beta:minor`, or `npm run release:beta:next` completes successfully
- [ ] npm shows the version under the `beta` dist-tag, not `latest`
- [ ] GitHub `Desktop Release` workflow for the `v*-beta.N` tag is green
- [ ] GitHub `Android APK Release` workflow for the same tag is green
- [ ] GitHub `Release Notes Sync` mirrored the beta entry into the prerelease body
### Stable release (or promotion)
- [ ] Run the pre-release sanity check (see above) and address any findings
- [ ] Ensure the intended release commit is already committed and the git worktree is clean before running any `release:*` patch/promote command
- [ ] Ensure local `npm run typecheck` passes on that exact commit before running any `release:*` patch/promote command
- [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors)
- [ ] The 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
- [ ] 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
- [ ] Verify the changelog heading follows strict `## X.Y.Z - YYYY-MM-DD` format
- [ ] `npm run release:patch` or `npm run release:promote` completes successfully
- [ ] `npm run release:patch`, `npm run release:minor`, or `npm run release:promote` completes successfully
- [ ] GitHub `Desktop Release` workflow for the `v*` tag is green
- [ ] GitHub `Android APK Release` workflow for the same tag is green
- [ ] EAS iOS production build for the same tag completes and submits via Fastlane
- [ ] EAS Android production build for the same tag completes and auto-submits to the Play Store
- [ ] 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

View File

@@ -3,14 +3,14 @@
New WebSocket session RPCs use dotted names with the direction as the final segment:
```ts
checkout.github.set_auto_merge.request;
checkout.github.set_auto_merge.response;
checkout.forge.set_auto_merge.request;
checkout.forge.set_auto_merge.response;
```
The namespace reads left to right:
- Domain: `checkout`
- Provider or subsystem: `github`
- Namespace segment: `forge`
- Operation: `set_auto_merge`; this segment is a verb, not a noun. If you would name an RPC `noun.request`, name it `get_noun.request` instead.
- Direction: `request` or `response`
@@ -21,8 +21,8 @@ Use dots, not slashes. Dots are protocol namespaces; slashes imply paths or tran
For ordinary correlated RPCs, a `.request` has a matching `.response` with the same prefix. The daemon client may derive the response type mechanically:
```ts
checkout.github.set_auto_merge.request;
// -> checkout.github.set_auto_merge.response
checkout.forge.set_auto_merge.request;
// -> checkout.forge.set_auto_merge.response
```
Most new RPCs should follow this shape. If a request does not have a one-to-one response, call that out in the code near the schema.
@@ -33,7 +33,7 @@ Requests keep their parameters at the top level:
```ts
{
type: "checkout.github.set_auto_merge.request",
type: "checkout.forge.set_auto_merge.request",
cwd: "/repo",
enabled: true,
mergeMethod: "squash",
@@ -45,7 +45,7 @@ Responses put correlated result data under `payload`:
```ts
{
type: "checkout.github.set_auto_merge.response",
type: "checkout.forge.set_auto_merge.response",
payload: {
cwd: "/repo",
enabled: true,
@@ -58,14 +58,16 @@ Responses put correlated result data under `payload`:
Keep `requestId` in both request and response payloads. It is the correlation key.
## Provider Namespacing
## Forge Namespacing
Provider-specific behavior belongs under the provider segment:
Forge-neutral behavior currently uses `checkout.forge.*` for checkout-scoped operations and `forge.search.*` for forge search; forge-specific names belong here only after schema and session handlers exist:
- `checkout.github.*` for GitHub-specific checkout operations
- `checkout.gitlab.*` for future GitLab-specific checkout operations
- `checkout.forge.*` for operations whose request/response shape is genuinely
forge-neutral and whose implementation dispatches through the forge resolver.
- `checkout.github.*` for existing GitHub-specific compatibility RPCs while
callers migrate to the neutral `checkout.forge.*` shape
Do not put GitHub-specific enums or semantics into generic checkout RPC names. A generic RPC should only exist when the behavior is genuinely provider-neutral.
Do not put GitHub-specific enums or semantics into `checkout.forge.*` RPC names. A generic forge RPC should only exist when the behavior is genuinely forge-neutral.
## Compatibility

106
docs/service-proxy.md Normal file
View File

@@ -0,0 +1,106 @@
# Service Proxy
Paseo proxies HTTP traffic to services running inside your workspaces. Localhost service URLs are always enabled; optional public aliases and a separate service-only listener can be layered on through config.
## How it works
When a `paseo.json` script of `"type": "service"` starts, Paseo assigns it a local port and registers a route in the service proxy. Incoming requests whose `Host` header matches the script's generated hostname are forwarded to that port.
The generated hostname is built from the script name, branch, and project:
```
<script>--<branch>--<project>.localhost
```
If the branch is `main` or `master`, the branch segment is omitted:
```
<script>--<project>.localhost
```
**Example:** a script named `dev` in the `miniweb` project on branch `feature/auth` would be reachable at:
```
dev--feature-auth--miniweb.localhost
```
Local and public routes use one combined leftmost label (`script--branch--project`). This keeps the hostname compatible with normal single-level wildcard DNS and TLS. If the combined label would exceed DNS's 63-character label limit, Paseo truncates it with a deterministic hash suffix to avoid collisions.
## Configuration
Add a `serviceProxy` block under `daemon` in `~/.paseo/config.json`:
```json
{
"version": 1,
"daemon": {
"serviceProxy": {
"listen": "0.0.0.0:8080",
"publicBaseUrl": "https://paseoapps.my.domain.com"
}
}
}
```
| Field | Required | Description |
| --------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `listen` | No | Starts a separate service-only listener at this address. If omitted, services are still reachable on the daemon listener via localhost hosts. |
| `publicBaseUrl` | No | Adds public service host aliases and public service links. If omitted, links use localhost addresses only. |
`enabled` is accepted for old configs but no longer enables a mode. `enabled: false` suppresses optional `listen`/`publicBaseUrl` layers only; localhost service proxying remains always enabled.
## DNS and reverse proxy setup
For generated URLs to be reachable, you need wildcard DNS pointing to the machine running the Paseo daemon.
**Example:** to expose services at `https://dev--miniweb.paseoapps.my.domain.com` where the daemon host is `10.1.1.1`:
1. Configure a wildcard DNS record:
```
*.paseoapps.my.domain.com → 10.1.1.1
```
2. Set `publicBaseUrl` to `https://paseoapps.my.domain.com` in your config.
3. If you put a reverse proxy (nginx, Caddy, Traefik, etc.) in front of Paseo, point it at either the daemon listener or the optional service-only listener and ensure it forwards the `Host` header unchanged. The proxy uses the `Host` header to route requests to the correct service — rewriting it will break routing.
Public service URLs expose the workspace service itself. Daemon password authentication protects daemon APIs; it does not protect proxied dev services.
If the same reverse proxy serves the daemon web UI over HTTPS, it must also set `X-Forwarded-Proto` so the web UI can auto-connect with `wss://`. The daemon trusts forwarded headers from loopback proxies by default. If your proxy reaches the daemon from another address, configure the proxy ranges explicitly:
```json
{
"version": 1,
"daemon": {
"trustedProxies": ["loopback", "172.16.0.0/12"]
}
}
```
`PASEO_TRUSTED_PROXIES` accepts the same comma-separated values, for example `loopback,172.16.0.0/12`. Use `true` only when the final trusted proxy overwrites client-supplied `X-Forwarded-*` headers.
Nginx example:
```nginx
server {
listen 443 ssl;
server_name *.paseoapps.my.domain.com;
location / {
proxy_pass http://10.1.1.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
## Environment variables
The listen address and public base URL can also be set via environment variables, which take precedence over `config.json`:
| Variable | Description |
| ------------------------------------- | ------------------------------------------------------------------------- |
| `PASEO_SERVICE_PROXY_ENABLED` | Compatibility shim; `false` suppresses optional public/listen layers only |
| `PASEO_SERVICE_PROXY_LISTEN` | Starts the optional service-only listener, e.g. `0.0.0.0:8080` |
| `PASEO_SERVICE_PROXY_PUBLIC_BASE_URL` | Adds public service aliases and links |

112
docs/terminal-activity.md Normal file
View File

@@ -0,0 +1,112 @@
# Terminal Activity Indicators
Paseo surfaces terminal activity as a tab indicator (the same "running" dot used by agents).
## Current state
Terminal activity is source-agnostic plumbing. `TerminalActivityTracker` holds the current per-terminal state and emits transitions to the manager, worker protocol, websocket subscription, app buckets, dots, and notifications.
The tracker defaults to unknown (`null`). Activity production lives outside terminal stream parsing: agent hook commands report coarse activity to the daemon's local `/api/terminal-activity` endpoint.
## Architecture
```
TerminalSession
├── TerminalActivityTracker one per session
│ ├── set(state) records the latest state
│ └── onChange(snapshot, previous) fires only on resolved-state transitions
└── onActivityChange({ activity, previous }) subscribed in TerminalManager
├── emits terminalsChanged terminal list/tab indicators only
└── subscribeTerminalActivity per-transition stream for notification policy
└── subscribeTerminalWorkspaceContributionChanged workspace status rollup only
```
`TerminalActivityTracker` is the single stateful object per session. It holds `{ state, changedAt }`, starts at unknown (`null`), and fires `onChange` only when the state actually changes.
Terminal directory snapshots (`terminalsChanged`) and workspace contribution changes are separate concerns. A title-only change produces a terminal list snapshot but never touches workspace descriptors. A transition that changes the derived workspace bucket (e.g. idle -> working, working -> idle, attention cleared) emits both a terminal list snapshot and a server-internal `TerminalWorkspaceContributionChanged` event, which Session consumes to invalidate every active workspace sharing the owning workspace's `cwd`.
### Transitions carry their own history
Each `onChange` delivers both the new snapshot and the `previous` one (`{ state, changedAt }`). The transition flows unchanged up through `TerminalSession.onActivityChange` (as `{ activity, previous }`), the worker protocol's `terminalActivityChange` event, and the manager-level `subscribeTerminalActivity(listener)` stream (`{ terminalId, name, cwd, activity, previous }`).
The daemon consumes these transitions, not snapshots. When a transition moves from `working` to `idle`, the tracker records finished attention, so the terminal shows the same green finished dot as an idle agent that needs review. The websocket layer also fires a "Terminal finished" attention notification. A terminal that exits while still working emits no turn-end notification.
Terminal list visibility is `workspaceId`-scoped: a terminal belongs to the workspace that created it, and same-`cwd` sibling workspaces do not see it in their terminal lists. Terminal status routing starts from that owning workspace, uses the owning workspace's `cwd`, then fans the status bucket out to every active workspace with the same `cwd`.
Path-prefix routing is only a legacy fallback for unowned terminal activity contribution. If a live terminal has no `workspaceId`, the daemon resolves the deepest active parent workspace from the terminal `cwd`, then fans status out to active same-`cwd` siblings of that owner. That fallback contributes status, but it does not make the terminal visible in workspace-scoped terminal lists.
## Hook reporting
Terminals receive four environment variables when the daemon creates the shell:
- `PASEO_TERMINAL_ID`
- `PASEO_ACTIVITY_TOKEN`
- `PASEO_TERMINAL_ACTIVITY_URL`
- `PASEO_HOOK_CLI` — absolute path to the current `paseo` CLI executable.
The generated shell command uses `PASEO_HOOK_CLI` to run the current CLI. `paseo hooks <agent> <event>` then reads the terminal id, token, and activity URL, asks the agent hook provider registry to resolve the event to a coarse activity state, and silently posts `{ terminalId, token, state }` to the activity URL. Missing env, unsupported agents/events, malformed hook input, and daemon/network failures are no-ops so agent hooks never break the user's terminal session.
Claude hook mapping:
- `UserPromptSubmit``running`
- `Stop`, `StopFailure`, `SessionEnd``idle`
- `Notification` with `reason` or `matcher` equal to `idle_prompt``needs-input`
Codex hook mapping:
- `UserPromptSubmit``running`
- `PreToolUse`, `PostToolUse``running`
- `PermissionRequest``needs-input`
- `Stop``idle`
OpenCode uses a server plugin instead of command hooks. The plugin listens to OpenCode bus events and emits these Paseo hook events:
- `session.status` with `busy` or `retry``running`
- `session.status` with `idle``idle`
- `permission.asked``needs-input`
- `permission.replied``running`
The daemon maps hook states onto terminal activity like an agent lifecycle plus unread attention: `running``state: working`, `idle``state: idle`, and `needs-input``state: idle` with `attentionReason: needs_input`. A `working``idle` transition records `state: idle` with `attentionReason: finished` until the user focuses that terminal; plain idle terminals still contribute no workspace status.
## Focus clearing
Client heartbeats include the focused terminal id. When a visible client focuses a terminal with an `attentionReason`, the daemon clears the attention and leaves the terminal idle. Plain idle terminal activity does not contribute to workspace status, so a workspace whose only attention source was that terminal rolls up from `needs_input` or `attention` back to `done`.
### Agent hook installation
Installing hooks edits the user's real agent config files, so it is opt-in. The daemon setting
`enableTerminalAgentHooks` (persisted under `daemon.enableTerminalAgentHooks`, default `false`)
gates installation. It is surfaced in the app under a host's **Terminals** settings as "Enable
terminal agent hooks" — "Get notifications and status from terminal agents. This installs hooks in
your agent config files." `applyTerminalAgentHookSetting` reconciles the installed hooks with the
setting: at startup it installs only when enabled; toggling the setting live installs on enable and
removes Paseo's marker-matched hooks on disable. `paseo hooks` keeps working regardless — the gate
only controls whether the daemon writes hooks into agent configs, not whether the CLI can post
activity when the env is present.
When enabled, Paseo installs provider hooks globally:
- Claude hooks are written to `~/.claude/settings.json` (or `CLAUDE_CONFIG_DIR/settings.json` when that override is set).
- Codex hooks are written to `~/.codex/hooks.json` (or `CODEX_HOME/hooks.json` when that override is set). Codex supports a native `commandWindows`, so each Paseo hook includes both POSIX and Windows commands. Non-managed Codex hooks are trust-gated by Codex; users may see Codex's hook review prompt before the hook runs.
- OpenCode gets a self-contained plugin at `$XDG_CONFIG_HOME/opencode/plugins/paseo-terminal-activity.js` (or `~/.config/opencode/plugins/paseo-terminal-activity.js` when XDG is unset; `OPENCODE_CONFIG_DIR` still wins when set).
Installation is marker-based/idempotent for config hooks and exact-file/idempotent for the OpenCode plugin. Paseo preserves user hooks, removes only its own marker-matched command hooks, and leaves hooks installed across daemon shutdown. Outside a Paseo terminal they are inert because the command or plugin is gated on `PASEO_TERMINAL_ID`.
Provider variation lives in `AGENT_HOOK_PROVIDERS`: provider id, installed events, config install metadata, and runtime event-to-activity resolution. The daemon calls `installRegisteredAgentHooks()` once; the CLI calls `resolveHookActivity(provider, event, input)`. Adding a provider should add one provider entry and register it in `AGENT_HOOK_PROVIDERS`, without editing the generic CLI command or daemon bootstrap.
The installed hook command keeps the config portable and resolves the CLI at runtime:
```sh
[ -n "$PASEO_TERMINAL_ID" ] && "${PASEO_HOOK_CLI:-paseo}" hooks claude <event>
```
Codex also receives the Windows equivalent:
```bat
if defined PASEO_TERMINAL_ID (if defined PASEO_HOOK_CLI ("%PASEO_HOOK_CLI%" hooks codex <event>) else (paseo hooks codex <event>))
```
Paseo injects `PASEO_HOOK_CLI` so Codex's hook shell cannot pick up a stale global `paseo` before the current one. The command still falls back to bare `paseo` if the env is missing, and it still no-ops outside Paseo terminals because the `PASEO_TERMINAL_ID` gate remains first. Paseo also prepends the CLI binary directory to each terminal `PATH` as a secondary fallback. All other behavior lives in `paseo hooks`: read the env, map the event, POST activity, and no-op/fail-open when anything is missing or unavailable.
If config installation fails, daemon startup and terminal spawn continue without terminal activity hooks.

View File

@@ -0,0 +1,40 @@
# Terminal performance
How terminal output stays low-latency, what the invariants are, and how to measure before/after any change to the pipeline. Read this before touching anything under `packages/server/src/terminal/` or `packages/app/src/terminal/runtime/`.
## The pipeline
```
pty (node-pty, forked worker process)
→ headless xterm parse (worker, snapshot fidelity)
→ TerminalOutputCoalescer (worker, ≤1 IPC message per 5ms per terminal)
→ process.send IPC → daemon main process
→ TerminalOutputCoalescer (per client stream, terminal-session-controller.ts)
→ binary ws frame (2-byte header + raw bytes)
→ client decode (daemon-client.ts) → stream router → emulator runtime
→ xterm.write (back-to-back; xterm batches internally)
```
Terminal frames share the daemon main event loop with all agent traffic. The `eventLoopDelay` block in the `ws_runtime_metrics` log line (every 30s in `daemon.log`) is the ground truth for "the daemon is busy" — p99/max there directly bound worst-case terminal frame delay.
## Invariants (the easy-to-break ones)
- **Coalescers are leading+trailing throttles.** The first chunk after an idle window flushes immediately (synchronously); only sustained bursts wait for the trailing timer. Reverting to trailing-only adds a full window (~5ms) to every keystroke echo.
- **Output coalescing happens in the worker, before IPC.** One `process.send` per pty chunk was a main-loop flood under build output. Non-output messages (snapshot/snapshotReady/titleChange/exit) must flush the coalescer first so ordering is preserved.
- **Coalesced output carries the LAST chunk's revision.** Snapshot replay dedup (`replayTerminalOutputAfterSnapshot`) skips buffered output with `revision <= replayRevision`; a merged batch with a lower revision would be wrongly skipped (lost output).
- **The input-mode tracker runs once per process boundary, not per hop.** The worker owns the authoritative tracker; the daemon caches the replay preamble from `getTerminalState` responses and `snapshotReady` messages. Do not reintroduce a per-chunk `feed()` on the daemon main loop.
- **Snapshot catch-up is backpressure-gated.** A stream falls back to a full snapshot only when `outputBytesSinceSnapshot > MAX_TERMINAL_OUTPUT_FRAME_BYTES` (256KB) **and** the client transport reports `bufferedAmount > MAX_CLIENT_BUFFERED_BYTES` (4MB). A client that keeps draining streams continuously, no matter how much output is produced. Before this gate existed, every 256KB of build output dropped a frame and forced a full JSON cell-grid snapshot (~200k objects across IPC) — the historical source of spiky lag and GC hitches.
- **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.
## Measuring
- **Node-only benchmark (fast iteration, server pipeline):** `npx tsx scripts/benchmark-terminal-latency.ts`. Boots an isolated daemon (fresh `PASEO_HOME`, random port — never 6767), measures echo latency percentiles, burst jitter, and snapshot counts under ramped mock-agent load. Writes JSON to `/tmp/paseo-terminal-bench/`. Healthy numbers (2026-06): echo p50 ~2.3ms, p95 ~3.3ms, a 2MB burst fully streamed with `snap=0`.
- **Browser perf specs (user-perceived path):** gated behind `PASEO_TERMINAL_PERF_E2E=1`
`packages/app/e2e/terminal-performance.spec.ts` and `packages/app/e2e/terminal-keystroke-stress.spec.ts` (per-stage keydown→xterm-commit breakdown under mock-agent load). Healthy: keydown→commit p50 ~18ms under 600-key burst.
- **Production:** grep `daemon.log` for `ws_runtime_metrics` and read `eventLoopDelay` + `bufferedAmount`.
## 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.
- Relay-attached clients pay pure-JS tweetnacl encryption + base64 per frame on the daemon main loop (`packages/relay/src/encrypted-channel.ts`).
- `sendToClient` re-stringifies session messages per socket; only matters for multi-socket connections.

View File

@@ -21,6 +21,18 @@ WRONG (horizontal):
Writing all tests first then all implementation produces bad tests — you end up testing imagined behavior instead of actual behavior.
## Fallible user actions
Every user action that can fail must expose the complete operation state in the UI:
- **Pending:** show immediate progress and prevent accidental duplicate submissions.
- **Success:** show the requested result, or a clear success acknowledgement when the result is not otherwise visible.
- **Failure:** keep an actionable error visible in the same context until the user retries or dismisses it.
Logs, console output, and a reset button are not user feedback. Neither is a platform API unless it is verified on every supported platform: React Native Web's `Alert.alert()` is a no-op, so browser and Electron failures must use rendered app UI such as the shared alert component.
Every fallible action needs behavioral coverage for success and failure. RPC-backed UI should use an app Playwright test with a real browser, network, and daemon whenever feasible. The failure test must assert what the user can see and do after the failure, not an internal response, state field, or log line. Add distinct timeout or disconnect cases when they produce distinct recovery behavior.
## Determinism first
Tests must produce the same result every run:
@@ -91,6 +103,37 @@ function createTestEmailSender() {
When a test is labeled end-to-end, it calls the real service. No environment variable gates, no conditional skipping, no mocking the external dependency.
### Packaged desktop smoke
The packaged desktop smoke is an external observer of the production launch path. It must not add a smoke-only branch to Electron main or start the daemon itself.
The harness launches the unpacked packaged app with isolated user data and daemon state, connects to the real renderer over Chromium's debugging protocol, and requires all of these outcomes:
- the `paseo://app/` renderer mounts into `#root`;
- the sandboxed preload exposes the desktop bridge;
- the renderer starts a fresh desktop-managed daemon through the normal startup bootstrap;
- the bundled CLI can query that daemon and run a terminal command.
Pull-request CI runs the Linux x64 smoke under Xvfb when the cumulative PR diff changes `packages/desktop/**`. The desktop release matrix runs the harness against each host-native packaged app before publishing. All smoke jobs upload renderer, desktop, and daemon diagnostics on failure.
To exercise the smoke locally on Linux:
```bash
PASEO_DESKTOP_SMOKE=1 \
PASEO_DESKTOP_SMOKE_ARTIFACT_DIR=/tmp/paseo-desktop-smoke \
npm run build:desktop -- --publish never --linux --x64 --dir
```
### Browser tab bridge regression
The desktop browser tab bridge E2E launches an isolated real daemon, Metro, and Electron app. It forces workspace LRU eviction to reparent the original tab and replace its guest `WebContents`, then makes one MCP call each for tab listing, snapshot, and click against that original browser id. A final MCP wait proves the real target page received the click.
Run it locally with the same command owned by the Ubuntu leg of the existing `desktop-tests` CI check:
```bash
npm run test:e2e:browser-tab-bridge --workspace=@getpaseo/desktop
```
## Test organization
- Collocate tests with implementation: `thing.ts` + `thing.test.ts`
@@ -113,8 +156,19 @@ Vitest picks up tests by suffix. The suffix tells the runner which category it b
App-level Playwright browser E2E lives in `packages/app/e2e/*.spec.ts` and runs via `npm run test:e2e --workspace=@getpaseo/app` (separate from Vitest E2E). App Playwright specs that hit real providers use `*.real.spec.ts` and run through `npm run test:e2e:real --workspace=@getpaseo/app`; the default app E2E project ignores that suffix so CI does not need provider credentials.
Desktop import coverage starts from the real product Integrations row and crosses the real Electron
main process, compiled sandboxed preload, production import IPC, the installed import library, and a
real Desktop-managed daemon. It covers confirmation, streamed output, success, failure, and
ineligible-host UI:
```bash
npm run test:import-electron --workspace=@getpaseo/desktop
```
Live provider smoke tests belong in `*.real.e2e.test.ts`, not `*.test.ts`, even when guarded by environment variables. Default unit suites must use deterministic provider adapters/fakes so missing credits, auth outages, and upstream model drift do not block normal CI.
Codex MultiAgentV2 real tests use local Codex authentication rather than the OpenRouter-compatible test provider. OpenRouter does not accept Codex collaboration-history items on the parent follow-up request, so it cannot verify a complete native sub-agent turn.
### Test setup
- Server: `packages/server/src/test-utils/vitest-setup.ts` loads `.env.test`, sets `PASEO_SUPERVISED=0`, and disables Git/SSH prompts. Add new global env shims here, not in individual tests.
@@ -130,6 +184,9 @@ Test suites in this repo are heavy. Running them in bulk freezes the machine, es
- Never re-run a suite another agent already reported green.
- For full-suite confidence, push to CI and check GitHub Actions.
- Never run the full Playwright E2E suite locally — defer whole-suite verification to CI. Targeted Playwright specs are allowed when you changed or need to prove that specific flow.
- App Playwright specs share one isolated daemon per run. Helpers that create projects or workspaces must remove the daemon project record during cleanup, not only delete the temp directory. Agent helpers must pass the intended `workspaceId` through to agent creation; never infer ownership from `cwd`.
- CI can shard app Playwright across multiple jobs; each shard still owns a full isolated daemon/relay/Metro stack from global setup. Helpers that restart the daemon must preserve the global setup environment, including disabled speech/local-model settings, so a restart does not change the tested surface or start background downloads.
- Global setup starts Metro before Wrangler, assigns Wrangler explicit distinct relay and inspector ports, and accepts Metro as ready only when `/status` returns `packager-status:running`. A generic TCP listener is not sufficient readiness evidence.
## Agent authentication in tests

View File

@@ -2,13 +2,18 @@
Agent chat delivery has two paths:
1. **Live stream**`agent_stream` WebSocket messages for immediacy.
2. **Authoritative history**`fetch_agent_timeline_request` for correctness.
1. **Live stream**`agent_stream` WebSocket messages for immediacy. These may be delta-shaped lifecycle updates.
2. **Authoritative history**`fetch_agent_timeline_request` for correctness. This always returns full projected timeline items, never lifecycle deltas.
The invariant is:
> If the daemon has committed timeline rows for an agent, any connected client that opens or resumes that agent eventually displays every row through the daemon's current tail.
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.
Provider history hydration applies the same rule so reopening an agent cannot restore an oversized
tool payload.
## Presence is not delivery
Client heartbeat reports presence:
@@ -24,19 +29,64 @@ Heartbeat is used for notification routing. It must not be used as a correctness
Large unbounded timeline responses can exceed relay frame limits, so catch-up uses bounded pages. Bounded does not mean partial.
Page limits are projected-item targets. A tool call lifecycle is one projected item even if it spans many source sequence numbers, and assistant/reasoning chunks are merged before counting. The response carries `seqStart`, `seqEnd`, `sourceSeqRanges`, and `collapsed` so clients can advance sequence cursors without rendering delta rows.
When the app fetches `direction: "after"` and the daemon responds with `hasNewer: true`, the app must immediately fetch the next page from `endCursor`. The catch-up is complete only when `hasNewer: false`.
Initialization timeouts guard lack of catch-up progress, not the full multi-page sync. A successful page that queues the next `after` page refreshes the watchdog.
The first load of an agent without a local cursor is different: it fetches a bounded latest tail page. Older history remains user-driven by scrolling upward.
## Durable item anchors
Provider message IDs are not guaranteed for every displayed item. Paseo-generated system errors are one example. Rendered item indices are not durable either because pagination and projection can merge source rows.
Actions that address a point in chat history, such as Fork, use the daemon timeline `epoch` plus the projected item's `seqEnd`. The app carries that position on the rendered assistant item for both live and fetched history. When adjacent projected chunks merge, the merged item retains the newer chunk's position.
The daemon validates that the epoch is current and the exact source sequence still exists before slicing rows. It slices before projection so later lifecycle updates cannot leak into the selected context.
## Resume behavior
When a client resumes with a known cursor, it catches up after that cursor to completion. It does not replace the view with a latest tail page, because tail pagination can skip the middle of a long background run.
When a client resumes without a cursor, it fetches the latest tail page.
## Selective and legacy delivery
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 short 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, a retained timeline stays covered when revisited until authoritative
catch-up completes; cached partial output is never presented as current history.
- Legacy daemons keep globally streaming agent timelines. Visibility still triggers the existing
authoritative catch-up, but the app does not issue selective-subscription RPCs.
This policy is owned by `viewed-timeline-sync.ts`; downstream reducers do not branch on daemon
version.
## Projected pages reconcile with live presentation
A projected page is canonical state, not a sequence of live deltas. One projected item can overlap
rows already received live—for example, a tool call retained at its original display position while
its completion advances `seqEnd`, followed by a merged assistant message. The app uses
`sourceSeqRanges` to replace overlapping assistant and reasoning projections before applying the
remaining page through the existing stream reducer. It must not append full projected text to a
live prefix.
Optimistic user prompts are presentation state rather than canonical history. Incremental catch-up
temporarily separates them, applies canonical entries, lets canonical user rows reconcile through
the existing optimistic-message rules, then restores any unmatched prompts after the caught-up
history. This keeps late history before a newly submitted prompt without duplicating an
acknowledged prompt.
## Relevant code
- Server live stream forwarding: `packages/server/src/server/session.ts`
- App sync planning: `packages/app/src/timeline/timeline-sync-plan.ts`
- App viewed-agent synchronization: `packages/app/src/timeline/viewed-timeline-sync.ts`
- App stream/timeline reducer: `packages/app/src/timeline/session-stream-reducers.ts`
- Session wiring: `packages/app/src/contexts/session-context.tsx`

View File

@@ -58,6 +58,30 @@ For standard React Native components, the [Unistyles Babel plugin](https://www.u
The important detail: the automatic native path tracks `props.style`. It does not generally track every prop that happens to carry style-like values.
### Do Not Materialize Styles At Module Scope
Never read a Unistyles style property into a module-level constant. This includes cached arrays:
```tsx
// Wrong: evaluated while the app may still be using the temporary system theme.
const ROW_STYLE = [settingsStyles.row, settingsStyles.rowBorder];
// Right: each style proxy is read when this view renders.
<View style={[settingsStyles.row, settingsStyles.rowBorder]} />;
```
Paseo starts with adaptive themes, then applies the persisted theme after async settings load. A
module-level read can therefore materialize the light style before a persisted dark theme is
active. If the view mounts after that theme change, React Native receives the stale light object;
Unistyles registers the node for future changes but does not retroactively replace its initial
props. Settings dividers once rendered light `#e4e4e7` inside a dark `#252B2A` card for exactly
this reason.
Render-time array syntax is intentional and exempt from the app's JSX array-allocation lint rule.
Keep the entries separate so each retains its Unistyles metadata. If composition is needed outside
JSX, create the array inside the component or in a `useMemo` that first runs when the component
mounts—never at module evaluation time.
[`useUnistyles()`](https://www.unistyl.es/v3/references/use-unistyles) is different. It gives React access to the current theme/runtime and can make a component re-render when those values change. Use it for values that must be rendered through React props, such as icon colors or small escape hatches. Do not expect direct reads from `UnistylesRuntime` to re-render a component; [issue #817](https://github.com/jpudysz/react-native-unistyles/issues/817) is a useful reminder of that invariant.
## Dynamic Pixel Styles On Web
@@ -88,6 +112,21 @@ Do not split a component into plain and Unistyles variants just to handle one hi
When a reusable component has a prop whose whole job is dynamic geometry, make that prop the seam. For example, `FloatingSurface.frameStyle` and `FloatingScrollView.style` own their own escape hatch so menu, tooltip, hover-card, and combobox callers can stay declarative instead of knowing about Unistyles internals.
Do not flatten a caller-provided style array and pass the flattened object back to a React Native component. Unistyles style entries carry `unistyles_*` metadata; flattening two entries produces one object with multiple metadata keys and triggers the runtime warning: "use array syntax instead of object syntax." Preserve caller styles as arrays, and only flatten the dynamic geometry value you explicitly own. If that owned value was flattened from a mixed style prop, strip `unistyles_*` metadata before sending it through `inlineUnistylesStyle`.
Do not register an existing Unistyles style inside another `StyleSheet.create` either. That also combines two metadata identities into one object. Reuse the original style directly at the component:
```tsx
// Wrong: sharedStyles.row already carries Unistyles metadata.
const styles = StyleSheet.create({ row: sharedStyles.row });
<View style={styles.row} />;
// Right: one registered style identity reaches the native view.
<View style={sharedStyles.row} />;
```
This mistake once produced tens of thousands of warnings from retained sidebar rows. Because React Native captures component stacks for warnings, the warning loop itself can consume enough CPU and memory to make the app appear blank.
## Main Gotcha: `contentContainerStyle`
`ScrollView.contentContainerStyle` is the canonical trap. It looks like a style prop, but it is not the same prop that Unistyles' remapped native component registers by default. The upstream tutorial calls this out directly in its [ScrollView Background Issue](https://www.unistyl.es/v3/tutorial/settings-screen#scrollview-background-issue) section.
@@ -289,6 +328,21 @@ That brief transition is expected with the current storage model. It makes track
If we ever need to avoid the transition entirely, store at least the theme preference in synchronous storage and configure Unistyles with `initialTheme`.
## 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 }`.
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.
- **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.
- **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.
- **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`.
## Debugging
To inspect what the Babel plugin sees, temporarily enable [`debug: true`](https://www.unistyl.es/v3/other/babel-plugin#debug) in `packages/app/babel.config.js`:
@@ -318,7 +372,7 @@ For paint-layer bugs, use high-contrast probes:
3. Screenshot the simulator and sample pixels to see which color fills the area.
4. Remove the probes before committing.
The welcome-screen investigation used this approach to prove the white layer was the `ScrollView` content container. Deep-dive evidence is in [welcome-theme-split-research.md](/Users/moboudra/.paseo/notes/welcome-theme-split-research.md).
The welcome-screen investigation used this approach to prove the white layer was the `ScrollView` content container.
## References
@@ -332,4 +386,3 @@ The welcome-screen investigation used this approach to prove the white layer was
- [GitHub issue #550: ScrollView sticky-header theme updates](https://github.com/jpudysz/react-native-unistyles/issues/550)
- [GitHub issue #817: `UnistylesRuntime.themeName` does not re-render](https://github.com/jpudysz/react-native-unistyles/issues/817)
- [GitHub issue #1030: `Image.tintColor` and native style update edge case](https://github.com/jpudysz/react-native-unistyles/issues/1030)
- [Local research note: welcome theme split](/Users/moboudra/.paseo/notes/welcome-theme-split-research.md)

View File

@@ -1 +1 @@
sha256-ngMXsN0UDo4ldTMQgDQ4r6XfAvwnwh4hzglIcmGYu9E=
sha256-MjAAaU+zU3m5fdE9rQrr4aA3eTDZztIXJQVXKbj73rY=

View File

@@ -30,9 +30,7 @@ buildNpmPackage rec {
relPath = lib.removePrefix (toString ./..) path;
in
# Exclude non-daemon workspace contents (keep package.json for workspace resolution)
!(lib.hasPrefix "/packages/app/src" relPath)
&& !(lib.hasPrefix "/packages/app/assets" relPath)
&& !(lib.hasPrefix "/packages/app/android" relPath)
!(lib.hasPrefix "/packages/app/android" relPath)
&& !(lib.hasPrefix "/packages/app/ios" relPath)
&& !(lib.hasPrefix "/packages/website/src" relPath)
&& !(lib.hasPrefix "/packages/website/public" relPath)
@@ -81,6 +79,7 @@ buildNpmPackage rec {
# Build all server packages in dependency order (defined in package.json)
npm run build:server
npm run build:daemon-web-ui
runHook postBuild
'';
@@ -107,6 +106,9 @@ buildNpmPackage rec {
# CLI/server bin starts from $out.
cp package.json $out/lib/paseo/
# Web UI Assets
cp -r packages/server/dist/server/web-ui $out/lib/paseo/packages/server/dist/server/
# Create wrapper for the server entry point (for systemd / direct use)
mkdir -p $out/bin
makeWrapper ${nodejs}/bin/node $out/bin/paseo-server \

7514
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +1,12 @@
{
"name": "paseo",
"version": "0.1.87",
"version": "0.2.0-beta.1",
"private": true,
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
"development",
"mcp",
"openai",
"realtime",
"voice",
"voice-assistant"
],
@@ -34,23 +33,32 @@
"packages/cli"
],
"scripts": {
"dev": "./scripts/dev.sh",
"dev": "npm run dev:server",
"dev:win": "powershell ./scripts/dev.ps1",
"dev:server": "npm run build:server-deps && concurrently --kill-others --names protocol,client,server --prefix-colors yellow,blue,cyan \"npm run watch:protocol\" \"npm run watch:client\" \"npm run dev:server:raw\"",
"dev:server": "cross-env PASEO_LISTEN=127.0.0.1:6768 ./scripts/dev-daemon.sh",
"dev:server:watch": "concurrently --kill-others --names protocol,client,server --prefix-colors yellow,blue,cyan \"npm run watch:protocol\" \"npm run watch:client\" \"npm run dev:server:raw\"",
"dev:server:raw": "npm run dev --workspace=@getpaseo/server",
"dev:app": "npm run start --workspace=@getpaseo/app",
"dev:app": "cross-env PASEO_LISTEN=127.0.0.1:6768 EXPO_PORT=8081 ./scripts/dev-app.sh",
"dev:website": "npm run dev --workspace=@getpaseo/website",
"postinstall": "node scripts/postinstall-patches.mjs",
"prepare": "lefthook install --force",
"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: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:protocol && npm run build --workspace=@getpaseo/client",
"build:client:clean": "npm run build:protocol:clean && npm run build:clean --workspace=@getpaseo/client",
"build:server-deps": "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": "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:daemon-web-ui": "node scripts/build-daemon-web-ui.mjs",
"build:app-deps": "npm run build:highlight && npm run build:client && npm run build --workspace=@getpaseo/expo-two-way-audio",
"watch:protocol": "tsc -p packages/protocol/tsconfig.json --watch --preserveWatchOutput",
"build:app-deps:clean": "npm run build:highlight:clean && npm run build:client:clean && npm run build --workspace=@getpaseo/expo-two-way-audio",
"watch:protocol": "npm run watch --workspace=@getpaseo/protocol",
"watch:client": "tsc -p packages/client/tsconfig.json --watch --preserveWatchOutput",
"typecheck": "npm run typecheck --workspaces --if-present",
"typecheck:server": "npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/protocol && npm run typecheck --workspace=@getpaseo/client && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli",
@@ -62,6 +70,9 @@
"lint": "oxlint",
"lint:fix": "oxlint --fix",
"knip": "knip",
"acp:version-drift": "node scripts/check-acp-catalog-version-drift.mjs",
"acp:version-drift:check": "node scripts/check-acp-catalog-version-drift.mjs --fail-on-drift",
"acp:version-drift:update": "node scripts/check-acp-catalog-version-drift.mjs --update",
"start": "npm run start --workspace=@getpaseo/server",
"android": "npm run android --workspace=@getpaseo/app",
"android:development": "npm run android:development --workspace=@getpaseo/app",
@@ -71,11 +82,11 @@
"android:clean": "npm run android:clean --workspace=@getpaseo/app",
"ios": "npm run ios --workspace=@getpaseo/app",
"web": "npm run web --workspace=@getpaseo/app",
"dev:desktop": "npm run dev --workspace=@getpaseo/desktop",
"dev:desktop": "cross-env PASEO_LISTEN=127.0.0.1:6768 npm run dev --workspace=@getpaseo/desktop",
"dev:win:desktop": "npm run dev:win --workspace=@getpaseo/desktop",
"build:desktop": "npm run build:app-deps && npm run build:server-deps && npm run build --workspace=@getpaseo/server && cd packages/app && cross-env PASEO_WEB_PLATFORM=electron npx expo export --platform web && cd ../.. && npm run build --workspace=@getpaseo/desktop --",
"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 --",
"cli": "npx tsx packages/cli/src/index.js",
"cli": "./scripts/dev-home.sh npx tsx packages/cli/src/index.js",
"version": "npm run version:sync-internal && npm run release:prepare && git add -A",
"version:sync-internal": "node scripts/sync-workspace-versions.mjs",
"release:prepare": "npm install --workspaces --include-workspace-root",
@@ -87,14 +98,16 @@
"version:all:beta:major": "node scripts/set-release-version.mjs --mode beta-major",
"version:all:beta:next": "node scripts/set-release-version.mjs --mode beta-next",
"version:all:promote": "node scripts/set-release-version.mjs --mode promote",
"release:check": "npm run release:prepare && npm run typecheck --workspace=@getpaseo/highlight && npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/protocol && npm run build:client && npm run typecheck --workspace=@getpaseo/client && npm run build:server && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && npm pack --dry-run --workspace=@getpaseo/highlight && npm pack --dry-run --workspace=@getpaseo/relay && npm pack --dry-run --workspace=@getpaseo/protocol && npm pack --dry-run --workspace=@getpaseo/client && npm pack --dry-run --workspace=@getpaseo/server && npm pack --dry-run --workspace=@getpaseo/cli",
"release:check": "npm run release:prepare && npm run typecheck --workspace=@getpaseo/highlight && npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/protocol && npm run build:client:clean && npm run typecheck --workspace=@getpaseo/client && npm run build:server:clean && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && npm pack --dry-run --workspace=@getpaseo/highlight && npm pack --dry-run --workspace=@getpaseo/relay && npm pack --dry-run --workspace=@getpaseo/protocol && npm pack --dry-run --workspace=@getpaseo/client && npm pack --dry-run --workspace=@getpaseo/server && npm pack --dry-run --workspace=@getpaseo/cli",
"release:publish:dry-run": "npm publish --dry-run --workspace=@getpaseo/highlight --access public && npm publish --dry-run --workspace=@getpaseo/relay --access public && npm publish --dry-run --workspace=@getpaseo/protocol --access public && npm publish --dry-run --workspace=@getpaseo/client --access public && npm publish --dry-run --workspace=@getpaseo/server --access public && npm publish --dry-run --workspace=@getpaseo/cli --access public",
"release:publish:beta:dry-run": "npm publish --dry-run --workspace=@getpaseo/highlight --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/relay --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/protocol --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/client --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/server --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/cli --access public --tag beta",
"release:publish": "npm publish --workspace=@getpaseo/highlight --access public && npm publish --workspace=@getpaseo/relay --access public && npm publish --workspace=@getpaseo/protocol --access public && npm publish --workspace=@getpaseo/client --access public && npm publish --workspace=@getpaseo/server --access public && npm publish --workspace=@getpaseo/cli --access public",
"release:publish:beta": "npm publish --workspace=@getpaseo/highlight --access public --tag beta && npm publish --workspace=@getpaseo/relay --access public --tag beta && npm publish --workspace=@getpaseo/protocol --access public --tag beta && npm publish --workspace=@getpaseo/client --access public --tag beta && npm publish --workspace=@getpaseo/server --access public --tag beta && npm publish --workspace=@getpaseo/cli --access public --tag beta",
"release:push": "node scripts/push-current-release-tag.mjs",
"release:beta:patch": "npm run release:check && npm run version:all:beta:patch && npm run release:push",
"release:beta:minor": "npm run release:check && npm run version:all:beta:minor && npm run release:push",
"release:beta:major": "npm run release:check && npm run version:all:beta:major && npm run release:push",
"release:beta:next": "npm run release:check && npm run version:all:beta:next && npm run release:push",
"release:beta:patch": "npm run release:check && npm run version:all:beta:patch && npm run release:publish:beta && npm run release:push",
"release:beta:minor": "npm run release:check && npm run version:all:beta:minor && npm run release:publish:beta && npm run release:push",
"release:beta:major": "npm run release:check && npm run version:all:beta:major && npm run release:publish:beta && npm run release:push",
"release:beta:next": "npm run release:check && npm run version:all:beta:next && npm run release:publish:beta && npm run release:push",
"release:promote": "npm run release:check && npm run version:all:promote && npm run release:publish && npm run release:push",
"release:patch": "npm run release:check && npm run version:all:patch && npm run release:publish && npm run release:push",
"release:minor": "npm run release:check && npm run version:all:minor && npm run release:publish && npm run release:push",

View File

@@ -1,3 +1,4 @@
source "https://rubygems.org"
gem "fastlane", "~> 2.234"
gem "multi_json"

View File

@@ -1,7 +1,75 @@
const fs = require("node:fs");
const path = require("node:path");
const pkg = require("./package.json");
const withFdroidAutolinking = require("./plugins/with-fdroid-autolinking");
const appVariant = process.env.APP_VARIANT ?? "production";
const isFdroidBuild = process.env.PASEO_FDROID_BUILD === "1";
const buildProfile = isFdroidBuild
? {
androidPermissions: [
"RECORD_AUDIO",
"android.permission.RECORD_AUDIO",
"android.permission.MODIFY_AUDIO_SETTINGS",
],
cameraPlugins: [],
fdroidPlugins: [withFdroidAutolinking],
notificationPlugins: [],
updates: { enabled: false },
}
: {
androidPermissions: [
"RECORD_AUDIO",
"android.permission.RECORD_AUDIO",
"android.permission.MODIFY_AUDIO_SETTINGS",
"CAMERA",
"android.permission.CAMERA",
],
cameraPlugins: [
[
"expo-camera",
{
cameraPermission:
"Allow $(PRODUCT_NAME) to access your camera to scan pairing QR codes.",
},
],
],
fdroidPlugins: [],
notificationPlugins: [
[
"expo-notifications",
{
icon: "./assets/images/notification-icon.png",
color: "#20744A",
},
],
],
updates: {},
};
function getNativeBuildVersionCode(version) {
const match = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(version);
if (!match) {
throw new Error(`Cannot derive Android versionCode from non-semver version: ${version}`);
}
const [, majorText, minorText, patchText] = match;
const major = Number(majorText);
const minor = Number(minorText);
const patch = Number(patchText);
if (minor > 999 || patch > 999) {
throw new Error(`Cannot derive collision-free Android versionCode from version: ${version}`);
}
const versionCode = major * 1_000_000 + minor * 1_000 + patch;
if (!Number.isSafeInteger(versionCode) || versionCode <= 0 || versionCode > 2_100_000_000) {
throw new Error(`Derived Android versionCode is out of range: ${versionCode}`);
}
return versionCode;
}
function resolveSecretFile(params) {
const fromEnv = process.env[params.envKey];
@@ -45,6 +113,7 @@ const variants = {
};
const variant = variants[appVariant] ?? variants.production;
const nativeBuildVersionCode = getNativeBuildVersionCode(pkg.version);
export default {
expo: {
@@ -61,6 +130,7 @@ export default {
},
updates: {
url: "https://u.expo.dev/0e7f65ce-0367-46c8-a238-2b65963d235a",
...buildProfile.updates,
},
ios: {
supportsTablet: true,
@@ -72,6 +142,7 @@ export default {
...(variant.googleServiceInfoPlist
? { googleServicesFile: variant.googleServiceInfoPlist }
: {}),
buildNumber: String(nativeBuildVersionCode),
},
android: {
adaptiveIcon: {
@@ -83,14 +154,9 @@ export default {
softwareKeyboardLayoutMode: "resize",
// Allow HTTP connections for local network hosts (required for release builds)
usesCleartextTraffic: true,
permissions: [
"RECORD_AUDIO",
"android.permission.RECORD_AUDIO",
"android.permission.MODIFY_AUDIO_SETTINGS",
"CAMERA",
"android.permission.CAMERA",
],
permissions: buildProfile.androidPermissions,
package: variant.packageId,
versionCode: nativeBuildVersionCode,
...(variant.googleServicesFile ? { googleServicesFile: variant.googleServicesFile } : {}),
},
web: {
@@ -102,12 +168,7 @@ export default {
},
plugins: [
"expo-router",
[
"expo-camera",
{
cameraPermission: "Allow $(PRODUCT_NAME) to access your camera to scan pairing QR codes.",
},
],
...buildProfile.cameraPlugins,
[
"expo-splash-screen",
{
@@ -120,14 +181,15 @@ export default {
},
},
],
...buildProfile.notificationPlugins,
"expo-audio",
[
"expo-notifications",
"expo-gradle-jvmargs",
{
icon: "./assets/images/notification-icon.png",
color: "#20744A",
xmx: "4096m",
maxMetaspace: "1024m",
},
],
"expo-audio",
[
"expo-build-properties",
{
@@ -139,6 +201,7 @@ export default {
},
},
],
...buildProfile.fdroidPlugins,
],
experiments: {
typedRoutes: true,
@@ -146,6 +209,7 @@ export default {
autolinkingModuleResolution: true,
},
extra: {
fdroidBuild: isFdroidBuild,
router: {},
eas: {
projectId: "0e7f65ce-0367-46c8-a238-2b65963d235a",

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

@@ -4,8 +4,9 @@ import { getServerId } from "./helpers/server-id";
import {
expectProviderInstalledInSettings,
installAcpCatalogProvider,
openAddProviderModal,
openAddProviderArea,
openSettingsHost,
openSettingsHostSection,
} from "./helpers/settings";
const ACP_PROVIDER = {
@@ -18,7 +19,9 @@ test.describe("ACP provider catalog", () => {
await gotoAppShell(page);
await openSettings(page);
await openSettingsHost(page, getServerId());
await openAddProviderModal(page);
// Providers moved to their own host section; add-provider lives there now.
await openSettingsHostSection(page, getServerId(), "providers");
await openAddProviderArea(page);
await installAcpCatalogProvider(page, ACP_PROVIDER.name);
await expectProviderInstalledInSettings(page, ACP_PROVIDER.name);

View File

@@ -0,0 +1,350 @@
import { randomUUID } from "node:crypto";
import { mkdtemp, rm, stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { test, expect, type Page } from "./fixtures";
import {
addProjectFlow,
addProjectFlowBack,
addProjectFlowHost,
addProjectFlowInput,
addProjectFlowMethod,
chooseAddProjectMethod,
expectAddProjectPage,
expectNewWorkspaceForAddedProject,
openAddProjectFlow,
waitForConnectedHost,
} from "./helpers/add-project-flow";
import { gotoAppShell } from "./helpers/app";
import { buildSeededHost } from "./helpers/daemon-registry";
import { addOfflineHostAndReload } from "./helpers/hosts";
import { type IsolatedHostDaemon, startIsolatedHostDaemon } from "./helpers/isolated-host-daemon";
import { expectOpenedProject } from "./helpers/project-picker-ui";
import { connectSeedClient } from "./helpers/seed-client";
import { getServerId } from "./helpers/server-id";
const EXTRA_HOSTS_KEY = "@paseo:e2e-extra-hosts";
const SECONDARY_HOST_ID = "add-project-flow-secondary";
const SECONDARY_HOST_LABEL = "Secondary Host";
async function addConnectedHostAndReload(page: Page, host: IsolatedHostDaemon): Promise<void> {
const registryEntry = buildSeededHost({
serverId: host.serverId,
label: SECONDARY_HOST_LABEL,
endpoint: `127.0.0.1:${host.port}`,
nowIso: new Date().toISOString(),
});
await page.evaluate(
({ key, entry }) => {
localStorage.setItem(key, JSON.stringify([entry]));
},
{ key: EXTRA_HOSTS_KEY, entry: registryEntry },
);
await page.reload();
}
async function expectProjectDirectory(pathname: string): Promise<void> {
await expect.poll(async () => (await stat(pathname)).isDirectory()).toBe(true);
}
async function removeCreatedProject(
pathname: string,
knownProjectId: string | null,
): Promise<void> {
const client = await connectSeedClient();
try {
let projectId = knownProjectId;
if (!projectId) {
const result = await client.addProject(pathname);
projectId = result.project?.projectId ?? null;
}
if (projectId) await client.removeProject(projectId).catch(() => undefined);
} finally {
await client.close();
}
}
async function expectProjectHasNoWorkspaces(projectId: string): Promise<void> {
const client = await connectSeedClient();
try {
const result = await client.fetchWorkspaces({ filter: { projectId } });
expect(result.entries).toEqual([]);
} finally {
await client.close();
}
}
test.describe("Add Project command-center flow", () => {
test.describe.configure({ timeout: 180_000 });
test("a single connected host opens directly on method selection", async ({ page }) => {
await gotoAppShell(page);
await openAddProjectFlow(page);
await expect(addProjectFlowMethod(page, "directory-search")).toBeVisible();
await expect(page.getByTestId("add-project-flow-page-host")).toHaveCount(0);
});
test("the back arrow, search input, and result glyph share one left edge", async ({ page }) => {
await gotoAppShell(page);
await openAddProjectFlow(page);
await page.keyboard.press("Enter");
await expectAddProjectPage(page, "directory-search");
await addProjectFlowInput(page).fill("/tmp");
const backGlyph = addProjectFlowBack(page).locator("svg");
const resultGlyph = addProjectFlow(page)
.locator('[data-testid^="add-project-flow-path-"]')
.first()
.locator("svg");
await expect(resultGlyph).toBeVisible();
const [backBox, inputBox, resultBox, titleBox, resultsBox, footerBox] = await Promise.all([
backGlyph.boundingBox(),
addProjectFlowInput(page).boundingBox(),
resultGlyph.boundingBox(),
page.getByTestId("add-project-flow-title").boundingBox(),
page.getByTestId("add-project-flow-results").boundingBox(),
page.getByTestId("add-project-flow-footer").boundingBox(),
]);
expect(backBox).not.toBeNull();
expect(inputBox).not.toBeNull();
expect(resultBox).not.toBeNull();
expect(titleBox).not.toBeNull();
expect(resultsBox).not.toBeNull();
expect(footerBox).not.toBeNull();
if (!backBox || !inputBox || !resultBox || !titleBox || !resultsBox || !footerBox) return;
expect(Math.abs(backBox.x - inputBox.x)).toBeLessThanOrEqual(2);
expect(Math.abs(resultBox.x - inputBox.x)).toBeLessThanOrEqual(2);
expect(titleBox.height).toBeLessThanOrEqual(24);
expect(resultsBox.y + resultsBox.height).toBeLessThanOrEqual(footerBox.y + 1);
});
test("an offline extra host neither appears nor forces host selection", async ({ page }) => {
await gotoAppShell(page);
await addOfflineHostAndReload(page, {
serverId: "add-project-flow-offline",
label: "Offline Host",
});
await openAddProjectFlow(page);
await expect(addProjectFlowHost(page, "add-project-flow-offline")).toHaveCount(0);
await expect(addProjectFlowMethod(page, "directory-search")).toBeVisible();
});
test.describe("with two connected hosts", () => {
let secondaryHost: IsolatedHostDaemon;
test.beforeAll(async () => {
secondaryHost = await startIsolatedHostDaemon(SECONDARY_HOST_ID);
});
test.afterAll(async () => {
await secondaryHost?.close();
});
test("keyboard selection chooses the second host", async ({ page }) => {
await gotoAppShell(page);
await addConnectedHostAndReload(page, secondaryHost);
await waitForConnectedHost(page, {
serverId: SECONDARY_HOST_ID,
endpoint: `localhost:${secondaryHost.port}`,
});
await openAddProjectFlow(page, "host");
await page.keyboard.press("ArrowDown");
await page.keyboard.press("Enter");
await expectAddProjectPage(page, "method");
await expect(addProjectFlow(page)).toContainText(SECONDARY_HOST_LABEL);
});
test("Escape and Back restore page input and active selection before closing at the root", async ({
page,
}) => {
await gotoAppShell(page);
await addConnectedHostAndReload(page, secondaryHost);
await waitForConnectedHost(page, {
serverId: SECONDARY_HOST_ID,
endpoint: `localhost:${secondaryHost.port}`,
});
await openAddProjectFlow(page, "host");
await addProjectFlowInput(page).fill("o");
await page.keyboard.press("ArrowDown");
await page.keyboard.press("Enter");
await expectAddProjectPage(page, "method");
await addProjectFlowInput(page).fill("new");
await page.keyboard.press("Enter");
await expectAddProjectPage(page, "new-directory-parent");
await page.keyboard.press("Escape");
await expectAddProjectPage(page, "method");
await expect(addProjectFlowInput(page)).toHaveValue("new");
await page.keyboard.press("Enter");
await expectAddProjectPage(page, "new-directory-parent");
await addProjectFlowBack(page).click();
await expectAddProjectPage(page, "method");
await addProjectFlowBack(page).click();
await expectAddProjectPage(page, "host");
await expect(addProjectFlowInput(page)).toHaveValue("o");
await page.keyboard.press("Enter");
await expectAddProjectPage(page, "method");
await expect(addProjectFlow(page)).toContainText(SECONDARY_HOST_LABEL);
await page.keyboard.press("Escape");
await expectAddProjectPage(page, "host");
await page.keyboard.press("Escape");
await expect(addProjectFlow(page)).not.toBeVisible();
});
test("New directory creates a Project on the selected remote host", async ({ page }) => {
const parentDirectory = await mkdtemp(path.join(tmpdir(), "paseo-e2e-remote-project-"));
const directoryName = `remote-${randomUUID().slice(0, 8)}`;
const directoryPath = path.join(parentDirectory, directoryName);
try {
await gotoAppShell(page);
await addConnectedHostAndReload(page, secondaryHost);
await waitForConnectedHost(page, {
serverId: SECONDARY_HOST_ID,
endpoint: `localhost:${secondaryHost.port}`,
});
await openAddProjectFlow(page, "host");
await addProjectFlowHost(page, SECONDARY_HOST_ID).click();
await expectAddProjectPage(page, "method");
await expect(addProjectFlowMethod(page, "new-directory")).toContainText(
`Create an empty directory on ${SECONDARY_HOST_LABEL}`,
);
await chooseAddProjectMethod(page, "new-directory");
await addProjectFlowInput(page).fill(parentDirectory);
await page.keyboard.press("Enter");
await expectAddProjectPage(page, "new-directory-name");
await page.keyboard.type(directoryName);
await page.keyboard.press("Enter");
const projectId = await expectOpenedProject(page, directoryName);
await expectNewWorkspaceForAddedProject(page, {
serverId: SECONDARY_HOST_ID,
projectId,
projectName: directoryName,
projectPath: directoryPath,
});
await expect(page.getByTestId("host-picker-trigger")).toContainText(SECONDARY_HOST_LABEL);
await expectProjectDirectory(directoryPath);
} finally {
await rm(parentDirectory, { recursive: true, force: true });
}
});
});
test("keyboard directory search adds the selected Project", async ({
page,
projectPickerFixture,
}) => {
await gotoAppShell(page);
await openAddProjectFlow(page);
await page.keyboard.press("Enter");
await expectAddProjectPage(page, "directory-search");
await page.keyboard.type(projectPickerFixture.fuzzyQuery);
await expect(addProjectFlow(page)).toContainText(projectPickerFixture.projectName, {
timeout: 30_000,
});
await page.keyboard.press("Enter");
const projectId = await expectOpenedProject(page, projectPickerFixture.projectName);
projectPickerFixture.rememberProjectId(projectId);
await expectNewWorkspaceForAddedProject(page, {
serverId: getServerId(),
projectId,
projectName: projectPickerFixture.projectName,
projectPath: projectPickerFixture.projectPath,
});
await expectProjectHasNoWorkspaces(projectId);
});
test("the current daemon advertises Clone from GitHub and New directory", async ({ page }) => {
await gotoAppShell(page);
await openAddProjectFlow(page);
await expect(addProjectFlowMethod(page, "github")).toContainText("Clone from GitHub");
await expect(addProjectFlowMethod(page, "new-directory")).toContainText("New directory");
});
test("a complete repository URL remains selectable without a GitHub search result", async ({
page,
}) => {
await gotoAppShell(page);
await openAddProjectFlow(page);
await chooseAddProjectMethod(page, "github");
const remote = "https://github.invalid/acme/manual.git";
await addProjectFlowInput(page).fill(remote);
await expect(addProjectFlow(page).getByText("manual", { exact: true })).toBeVisible();
await page.keyboard.press("Enter");
await expectAddProjectPage(page, "github-location");
const title = addProjectFlow(page).getByTestId("add-project-flow-title");
await expect(title.getByText("Choose destination", { exact: true })).toBeVisible();
await expect(title.getByText("localhost", { exact: true })).toBeVisible();
await expect(title).not.toContainText("Where should Paseo create");
await addProjectFlowBack(page).click();
await expect(addProjectFlowInput(page)).toHaveValue(remote);
});
test("New directory validates the name, restores parent and name state, then creates a Project", async ({
page,
}) => {
const parentDirectory = await mkdtemp(path.join(tmpdir(), "paseo-e2e-new-project-"));
const directoryName = `created-${randomUUID().slice(0, 8)}`;
const directoryPath = path.join(parentDirectory, directoryName);
let projectId: string | null = null;
try {
await gotoAppShell(page);
await openAddProjectFlow(page);
await chooseAddProjectMethod(page, "new-directory");
await page.keyboard.type(parentDirectory);
await page.keyboard.press("Enter");
await expectAddProjectPage(page, "new-directory-name");
await page.keyboard.type("../invalid");
await page.keyboard.press("Enter");
const error = page.getByTestId("add-project-flow-error");
await expect(error).toBeVisible();
await expect(error).toContainText(/name|separator|directory/i);
await expectAddProjectPage(page, "new-directory-name");
await addProjectFlowInput(page).fill(directoryName);
await addProjectFlowBack(page).click();
await expectAddProjectPage(page, "new-directory-parent");
await expect(addProjectFlowInput(page)).toHaveValue(parentDirectory);
await page.keyboard.press("Enter");
await expectAddProjectPage(page, "new-directory-name");
await expect(addProjectFlowInput(page)).toHaveValue(directoryName);
await page.keyboard.press("Enter");
projectId = await expectOpenedProject(page, directoryName);
await expectNewWorkspaceForAddedProject(page, {
serverId: getServerId(),
projectId,
projectName: directoryName,
projectPath: directoryPath,
});
await expectProjectHasNoWorkspaces(projectId);
await expectProjectDirectory(directoryPath);
} finally {
await removeCreatedProject(directoryPath, projectId).catch(() => undefined);
await rm(parentDirectory, { recursive: true, force: true });
}
});
});

View File

@@ -0,0 +1,96 @@
import { mkdir, mkdtemp, rm, stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { expect, test } from "./fixtures";
import {
addProjectFlow,
addProjectFlowBack,
addProjectFlowInput,
chooseAddProjectMethod,
expectAddProjectPage,
expectNewWorkspaceForAddedProject,
openAddProjectFlow,
} from "./helpers/add-project-flow";
import { gotoAppShell } from "./helpers/app";
import { createTempGithubRepo, hasGithubAuth, type GhRepoFixture } from "./helpers/github-fixtures";
import { expectOpenedProject } from "./helpers/project-picker-ui";
import { connectSeedClient } from "./helpers/seed-client";
import { getServerId } from "./helpers/server-id";
test.describe("Add Project GitHub flow", () => {
test.describe.configure({ timeout: 300_000 });
test("searches the host's repositories and clones into a clearly shown final path", async ({
page,
}) => {
test.skip(!hasGithubAuth(), "Requires GitHub authentication (gh auth login)");
let repository: GhRepoFixture | null = null;
const parentDirectory = await mkdtemp(path.join(tmpdir(), "paseo-e2e-github-clone-"));
let projectId: string | null = null;
try {
repository = await createTempGithubRepo({ category: "add-project" });
const checkoutPath = path.join(parentDirectory, repository.name);
await gotoAppShell(page);
await openAddProjectFlow(page);
await chooseAddProjectMethod(page, "github");
await addProjectFlowInput(page).fill("getpaseo/paseo");
await expect(addProjectFlow(page).getByText("getpaseo/paseo", { exact: true })).toBeVisible({
timeout: 30_000,
});
await addProjectFlowInput(page).fill("");
const repositoryRow = addProjectFlow(page).getByText(repository.fullName, { exact: true });
await expect(repositoryRow).toBeVisible({ timeout: 30_000 });
await repositoryRow.click();
await expectAddProjectPage(page, "github-location");
await addProjectFlowInput(page).fill(parentDirectory);
await expect(addProjectFlow(page)).toContainText(checkoutPath, { timeout: 30_000 });
await addProjectFlowBack(page).click();
await expectAddProjectPage(page, "github-search");
await expect(repositoryRow).toBeVisible();
await repositoryRow.click();
await expectAddProjectPage(page, "github-location");
await expect(addProjectFlowInput(page)).toHaveValue(parentDirectory);
await mkdir(checkoutPath);
await page.keyboard.press("Enter");
await expect(page.getByTestId("add-project-flow-error")).toHaveText(
`Checkout path already exists: ${checkoutPath}`,
);
await rm(checkoutPath, { recursive: true });
await page.keyboard.press("Enter");
projectId = await expectOpenedProject(page, repository.name);
await expectNewWorkspaceForAddedProject(page, {
serverId: getServerId(),
projectId,
projectName: repository.name,
projectPath: checkoutPath,
});
const client = await connectSeedClient();
try {
expect((await client.fetchWorkspaces({ filter: { projectId } })).entries).toEqual([]);
} finally {
await client.close();
}
await expect.poll(async () => (await stat(checkoutPath)).isDirectory()).toBe(true);
} finally {
if (projectId) {
const client = await connectSeedClient();
try {
await client.removeProject(projectId).catch(() => undefined);
} finally {
await client.close();
}
}
await repository?.cleanup();
await rm(parentDirectory, { recursive: true, force: true });
}
});
});

View File

@@ -1,4 +1,4 @@
import { test } from "./fixtures";
import { test, expect } from "./fixtures";
import {
awaitAssistantMessage,
expectAgentIdle,
@@ -6,13 +6,26 @@ import {
expectTurnCopyButton,
expectScrollFollowsNewContent,
} from "./helpers/agent-stream";
import {
expectScrollStaysFixed,
clickToolCallBesideScrollToBottomButton,
readScrollMetrics,
scrollAgentChatToBottom,
scrollChatAwayFromBottom,
waitForScrollableChat,
} from "./helpers/agent-bottom-anchor";
import { delayCreatedAgentInitialTailResponse } from "./helpers/agent-timeline-gate";
import { selectModel } from "./helpers/app";
import { clickNewChat } from "./helpers/launcher";
import { startRunningMockAgent } from "./helpers/composer";
import { expectComposerVisible, startRunningMockAgent } from "./helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
const SCROLL_AWAY_MIN_SCROLLABLE_DISTANCE = 360;
test.describe("Agent stream UI", () => {
test("auto-scroll sticks to bottom across token bursts", async ({ page }) => {
test.setTimeout(120_000);
const { client, repo } = await startRunningMockAgent(page, {
const agent = await startRunningMockAgent(page, {
prefix: "stream-scroll-",
model: "one-minute-stream",
prompt: "Stream for auto-scroll test.",
@@ -21,14 +34,212 @@ test.describe("Agent stream UI", () => {
await awaitAssistantMessage(page);
await expectScrollFollowsNewContent(page);
} finally {
await client.close();
await repo.cleanup();
await agent.cleanup();
}
});
test("keeps the active Markdown root mounted across streamed text updates", async ({
page,
}, testInfo) => {
test.setTimeout(120_000);
const agent = await startRunningMockAgent(page, {
prefix: "stream-markdown-root-",
model: "one-minute-stream",
prompt: "Stream for Markdown root stability test.",
});
try {
const assistantMessage = page.getByTestId("assistant-message").last();
await expect(assistantMessage).toContainText("walking through", { timeout: 30_000 });
const activeBlock = assistantMessage.locator(":scope > *").last();
const initialText = (await activeBlock.textContent()) ?? "";
const activeBlockHandle = await activeBlock.elementHandle();
if (!activeBlockHandle) {
throw new Error("Expected the active assistant message to contain a block");
}
const markdownRoot = await activeBlock.locator(":scope > *").first().elementHandle();
if (!markdownRoot) {
throw new Error("Expected the active assistant block to contain a Markdown root");
}
await page.evaluate((block) => {
const evidence = {
addedNodes: 0,
characterDataMutations: 0,
removedNodes: 0,
};
const observer = new MutationObserver((records) => {
for (const record of records) {
evidence.addedNodes += record.addedNodes.length;
evidence.removedNodes += record.removedNodes.length;
if (record.type === "characterData") {
evidence.characterDataMutations += 1;
}
}
});
observer.observe(block, { characterData: true, childList: true, subtree: true });
Object.assign(window, {
__markdownRootEvidence: evidence,
__markdownRootObserver: observer,
});
}, activeBlockHandle);
await expect
.poll(async () => ((await activeBlock.textContent()) ?? "").length)
.toBeGreaterThan(initialText.length + 80);
const evidence = await page.evaluate((root) => {
const state = window as typeof window & {
__markdownRootEvidence?: {
addedNodes: number;
characterDataMutations: number;
removedNodes: number;
};
__markdownRootObserver?: MutationObserver;
};
state.__markdownRootObserver?.disconnect();
const messages = document.querySelectorAll('[data-testid="assistant-message"]');
const message = messages.item(messages.length - 1);
const block = message?.lastElementChild;
return {
...state.__markdownRootEvidence,
connected: root.isConnected,
sameRoot: block?.firstElementChild === root,
};
}, markdownRoot);
await testInfo.attach("markdown-root-stability", {
body: JSON.stringify(evidence, null, 2),
contentType: "application/json",
});
expect(evidence.connected).toBe(true);
expect(evidence.sameRoot).toBe(true);
expect(
evidence.removedNodes,
`Streaming Markdown replaced mounted descendants: ${JSON.stringify(evidence)}`,
).toBe(0);
} finally {
await agent.cleanup();
}
});
test("keeps the viewport fixed after the user scrolls away during a stream", async ({ page }) => {
test.setTimeout(120_000);
const agent = await seedMockAgentWorkspace({
repoPrefix: "stream-scroll-away-",
title: "Scroll-away anchor",
model: "five-minute-stream",
initialPrompt: "emit 120 agent stream updates for scroll-away setup.",
});
try {
await agent.client.waitForFinish(agent.agentId, 30_000);
await openAgentRoute(page, {
workspaceId: agent.workspaceId,
agentId: agent.agentId,
});
await expectComposerVisible(page);
await agent.client.sendAgentMessage(agent.agentId, "Stream for scroll-away anchor test.");
await expect(page.getByRole("button", { name: /stop|cancel/i }).first()).toBeVisible({
timeout: 30_000,
});
await awaitAssistantMessage(page);
await waitForScrollableChat(page, {
minScrollableDistance: SCROLL_AWAY_MIN_SCROLLABLE_DISTANCE,
timeout: 30_000,
});
const baseline = await scrollChatAwayFromBottom(page, {
deltaY: -900,
minDistanceFromBottom: 300,
});
await expectScrollStaysFixed(page, baseline, { durationMs: 30_000 });
const finalMetrics = await readScrollMetrics(page);
expect(finalMetrics.contentHeight).toBeGreaterThan(baseline.contentHeight);
} finally {
await agent.cleanup();
}
});
test("keeps the viewport fixed when delayed authoritative history arrives after scroll-away", async ({
page,
withWorkspace,
}) => {
test.setTimeout(180_000);
const timelineGate = await delayCreatedAgentInitialTailResponse(page);
const workspace = await withWorkspace({
prefix: "stream-scroll-away-delayed-history-",
});
await workspace.navigateTo();
await clickNewChat(page);
await page.getByText("Model defaults are still loading").waitFor({
state: "hidden",
timeout: 30_000,
});
await expectComposerVisible(page);
await selectModel(page, "Five minute stream");
const prompt = "Stream for delayed authoritative history scroll-away test.";
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
await composer.fill(prompt);
await page.getByRole("button", { name: "Send message" }).click();
await page.getByText(prompt, { exact: true }).first().waitFor({
state: "visible",
timeout: 30_000,
});
await timelineGate.waitForCreatedAgent();
await timelineGate.waitForDelayedResponse();
await expect(page.getByRole("button", { name: /stop|cancel/i }).first()).toBeVisible({
timeout: 30_000,
});
await awaitAssistantMessage(page);
await waitForScrollableChat(page, {
minScrollableDistance: SCROLL_AWAY_MIN_SCROLLABLE_DISTANCE,
timeout: 45_000,
});
const baseline = await scrollChatAwayFromBottom(page, {
deltaY: -900,
minDistanceFromBottom: 300,
});
timelineGate.release();
await timelineGate.waitForForwardedResponse();
await expectScrollStaysFixed(page, baseline);
});
test("keeps tool calls clickable beside the scroll-to-bottom button", async ({ page }) => {
test.setTimeout(60_000);
const agent = await seedMockAgentWorkspace({
repoPrefix: "stream-scroll-button-hit-area-",
title: "Scroll button hit area",
model: "ten-second-stream",
initialPrompt: "Stream enough content to exercise the scroll button hit area.",
});
try {
await agent.client.waitForFinish(agent.agentId, 30_000);
await openAgentRoute(page, {
workspaceId: agent.workspaceId,
agentId: agent.agentId,
});
await waitForScrollableChat(page, {
minScrollableDistance: SCROLL_AWAY_MIN_SCROLLABLE_DISTANCE,
timeout: 30_000,
});
const hitArea = await clickToolCallBesideScrollToBottomButton(page);
expect(hitArea).toEqual({
outsideButton: true,
toolCallReceivesPointer: true,
withinButtonBand: true,
});
} finally {
await agent.cleanup();
}
});
test("working-indicator transitions to copy-button when stream ends", async ({ page }) => {
test.setTimeout(60_000);
const { client, repo } = await startRunningMockAgent(page, {
const agent = await startRunningMockAgent(page, {
prefix: "stream-indicator-",
model: "ten-second-stream",
prompt: "Stream briefly for indicator transition test.",
@@ -37,10 +248,10 @@ test.describe("Agent stream UI", () => {
await awaitAssistantMessage(page);
await expectInlineWorkingIndicator(page);
await expectAgentIdle(page, 30_000);
await scrollAgentChatToBottom(page);
await expectTurnCopyButton(page);
} finally {
await client.close();
await repo.cleanup();
await agent.cleanup();
}
});

View File

@@ -0,0 +1,28 @@
import { test } from "./fixtures";
import {
expectTimelinePromptNotMounted,
expectTimelinePromptVisible,
openAgentTimeline,
scrollTimelineUntilOlderHistoryIsReachable,
seedLongMockAgentTimeline,
} from "./helpers/timeline-pagination";
test.describe("Agent timeline pagination", () => {
test("loads older history when the user scrolls to the top of a long agent timeline", async ({
page,
}) => {
test.setTimeout(120_000);
const agent = await seedLongMockAgentTimeline({ turns: 80 });
try {
await openAgentTimeline(page, agent);
await expectTimelinePromptVisible(page, agent.newestPrompt);
await expectTimelinePromptNotMounted(page, agent.oldestPrompt);
await scrollTimelineUntilOlderHistoryIsReachable(page);
await expectTimelinePromptVisible(page, agent.oldestPrompt);
} finally {
await agent.cleanup();
}
});
});

View File

@@ -1,18 +1,20 @@
import { randomUUID } from "node:crypto";
import { expect } from "@playwright/test";
import { test } from "./fixtures";
import { getServerId } from "./helpers/server-id";
import { connectSeedClient } from "./helpers/seed-client";
import { createTempGitRepo } from "./helpers/workspace";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import {
archiveAgentFromDaemon,
archiveAgentFromSessions,
clickSessionRow,
closeWorkspaceAgentTab,
createIdleAgent,
expectArchivedAgentFocused,
expectSessionRowArchived,
expectSessionRowVisible,
expectWorkspaceArchiveOutcome,
expectWorkspaceTabHidden,
fetchAgentArchivedAt,
openSessions,
openWorkspaceWithAgents,
primeAdditionalPage,
@@ -23,15 +25,26 @@ import {
test.describe("Archive tab reconciliation", () => {
let client: Awaited<ReturnType<typeof connectSeedClient>>;
let tempRepo: { path: string; cleanup: () => Promise<void> };
let projectId: string;
let workspaceId: string;
test.describe.configure({ timeout: 300_000 });
test.beforeAll(async () => {
tempRepo = await createTempGitRepo("archive-tab-");
client = await connectSeedClient();
const created = await client.createWorkspace({
source: { kind: "directory", path: tempRepo.path },
});
if (!created.workspace) {
throw new Error(created.error ?? `Failed to create workspace ${tempRepo.path}`);
}
projectId = created.workspace.projectId;
workspaceId = created.workspace.id;
});
test.afterAll(async () => {
await client?.removeProject(projectId).catch(() => undefined);
await client?.close().catch(() => undefined);
await tempRepo?.cleanup();
});
@@ -39,10 +52,12 @@ test.describe("Archive tab reconciliation", () => {
test("non-UI archive prunes the archived tab across open pages and reload", async ({ page }) => {
const archived = await createIdleAgent(client, {
cwd: tempRepo.path,
workspaceId,
title: `cli-archive-${randomUUID().slice(0, 8)}`,
});
const surviving = await createIdleAgent(client, {
cwd: tempRepo.path,
workspaceId,
title: `cli-control-${randomUUID().slice(0, 8)}`,
});
const passivePage = await page.context().newPage();
@@ -68,7 +83,7 @@ test.describe("Archive tab reconciliation", () => {
archivedAgentId: archived.id,
survivingAgentId: surviving.id,
});
await reloadWorkspace(passivePage, tempRepo.path);
await reloadWorkspace(passivePage, surviving.workspaceId);
await expectWorkspaceTabHidden(passivePage, archived.id);
} finally {
await passivePage.close();
@@ -78,10 +93,12 @@ test.describe("Archive tab reconciliation", () => {
test("Sessions archive prunes the archived tab across open pages", async ({ page }) => {
const archived = await createIdleAgent(client, {
cwd: tempRepo.path,
workspaceId,
title: `ui-archive-${randomUUID().slice(0, 8)}`,
});
const surviving = await createIdleAgent(client, {
cwd: tempRepo.path,
workspaceId,
title: `ui-control-${randomUUID().slice(0, 8)}`,
});
const passivePage = await page.context().newPage();
@@ -94,7 +111,7 @@ test.describe("Archive tab reconciliation", () => {
await openWorkspaceWithAgents(passivePage, [archived, surviving]);
await openSessions(page);
await archiveAgentFromSessions(page, { agentId: archived.id, title: archived.title });
await reloadWorkspace(page, tempRepo.path);
await reloadWorkspace(page, surviving.workspaceId);
await expectWorkspaceTabHidden(page, archived.id);
await expectWorkspaceArchiveOutcome(passivePage, {
archivedAgentId: archived.id,
@@ -105,25 +122,32 @@ test.describe("Archive tab reconciliation", () => {
}
});
test("clicking an archived session reopens its closed tab focused", async ({ page }) => {
test("clicking an archived session navigates without unarchiving it", async ({ page }) => {
const archived = await createIdleAgent(client, {
cwd: tempRepo.path,
title: `reopen-archived-${randomUUID().slice(0, 8)}`,
workspaceId,
title: `unarchive-archived-${randomUUID().slice(0, 8)}`,
});
const surviving = await createIdleAgent(client, {
cwd: tempRepo.path,
title: `reopen-control-${randomUUID().slice(0, 8)}`,
workspaceId,
title: `unarchive-control-${randomUUID().slice(0, 8)}`,
});
await resetSeededPageState(page);
await openWorkspaceWithAgents(page, [archived, surviving]);
await closeWorkspaceAgentTab(page, archived.id);
await archiveAgentFromDaemon(client, archived.id);
const archivedAt = await fetchAgentArchivedAt(client, archived.id);
expect(archivedAt).not.toBeNull();
await openSessions(page);
await expectSessionRowArchived(page, archived.title);
await clickSessionRow(page, archived.title);
await expectArchivedAgentFocused(page, archived.id);
expect(await fetchAgentArchivedAt(client, archived.id)).toBe(archivedAt);
await expect(page).toHaveURL(buildHostWorkspaceRoute(getServerId(), archived.workspaceId), {
timeout: 30_000,
});
});
});

View File

@@ -0,0 +1,213 @@
import { expect, test as base, type Page } from "./fixtures";
import { scrollAgentChatToBottom } from "./helpers/agent-bottom-anchor";
import { awaitAssistantMessage } from "./helpers/agent-stream";
import { expectComposerVisible, submitMessage } from "./helpers/composer";
import { getE2EDaemonPort } from "./helpers/daemon-port";
import {
openAgentRoute,
seedMockAgentWorkspace,
type MockAgentOptions,
type MockAgentWorkspace,
} from "./helpers/mock-agent";
import { getServerId } from "./helpers/server-id";
import { seedSavedSettingsHosts } from "./helpers/settings";
import { submitNewWorkspaceEmpty } from "./helpers/new-workspace";
const test = base.extend<{
seedForkWorkspace: (options: MockAgentOptions) => Promise<MockAgentWorkspace>;
}>({
seedForkWorkspace: async ({ browserName: _browserName }, provide) => {
const sessions: MockAgentWorkspace[] = [];
await provide(async (options) => {
const session = await seedMockAgentWorkspace(options);
sessions.push(session);
return session;
});
await Promise.allSettled(sessions.map((session) => session.cleanup()));
},
});
async function openAssistantForkMenu(page: Page): Promise<void> {
await expect
.poll(
async () => {
await scrollAgentChatToBottom(page);
return page.getByTestId("assistant-fork-menu-trigger").count();
},
{ timeout: 30_000 },
)
.toBeGreaterThan(0);
const trigger = page.getByTestId("assistant-fork-menu-trigger").last();
await expect(trigger).toBeVisible({ timeout: 30_000 });
await trigger.click();
await expect(page.getByTestId("assistant-fork-menu-content")).toBeVisible({
timeout: 10_000,
});
}
async function expectChatHistoryPill(page: Page): Promise<void> {
const pill = page.getByTestId("composer-chat-history-attachment-pill").first();
await expect(pill).toBeVisible({ timeout: 30_000 });
await expect(pill).toContainText("Chat history");
}
test.describe("Assistant fork menu", () => {
test.describe.configure({ timeout: 180_000 });
test("forks a failed assistant turn that has no provider message id", async ({
page,
seedForkWorkspace,
}) => {
const session = await seedForkWorkspace({
repoPrefix: "assistant-fork-failed-turn-",
title: "Assistant fork failed turn",
model: "ten-second-stream",
});
await openAgentRoute(page, session);
await expectComposerVisible(page);
await submitMessage(page, "Emit a synthetic turn failure.");
await expect(page.getByText("[System Error] Requested mock provider failure")).toBeVisible({
timeout: 30_000,
});
await openAssistantForkMenu(page);
await page.getByTestId("assistant-fork-menu-new-tab").click();
await expectChatHistoryPill(page);
});
test("focuses a forked assistant turn in a new workspace draft tab", async ({
page,
seedForkWorkspace,
}) => {
const session = await seedForkWorkspace({
repoPrefix: "assistant-fork-focused-tab-",
title: "Assistant fork focused tab",
initialPrompt: "emit 1 coalesced agent stream updates for initial assistant fork turn.",
model: "ten-second-stream",
});
await openAgentRoute(page, session);
await expectComposerVisible(page);
await awaitAssistantMessage(page);
await session.client.waitForFinish(session.agentId, 45_000);
await submitMessage(page, "emit 1 coalesced agent stream updates while this tab is visible.");
await session.client.waitForFinish(session.agentId, 45_000);
await awaitAssistantMessage(page);
const agentTab = page.getByTestId(`workspace-tab-agent_${session.agentId}`);
await expect(agentTab).toHaveAttribute("aria-selected", "true");
await openAssistantForkMenu(page);
await page.getByTestId("assistant-fork-menu-new-tab").click();
const selectedTab = page
.getByTestId("workspace-tabs-row")
.getByRole("button")
.and(page.locator('[aria-selected="true"]'));
await expect(selectedTab).toHaveAttribute("data-testid", /^workspace-tab-draft_/, {
timeout: 30_000,
});
await expect(agentTab).toHaveAttribute("aria-selected", "false");
await expectChatHistoryPill(page);
});
test("keeps the fork attachment after submitting an existing-workspace draft tab", async ({
page,
seedForkWorkspace,
}) => {
const session = await seedForkWorkspace({
repoPrefix: "assistant-fork-tab-submit-",
title: "Assistant fork tab submit",
initialPrompt: "emit 1 coalesced agent stream updates for assistant fork tab submit.",
model: "ten-second-stream",
});
await openAgentRoute(page, session);
await expectComposerVisible(page);
await awaitAssistantMessage(page);
await session.client.waitForFinish(session.agentId, 45_000);
await openAssistantForkMenu(page);
await page.getByTestId("assistant-fork-menu-new-tab").click();
await expectChatHistoryPill(page);
await submitMessage(page, "");
const userMessage = page.getByTestId("user-message").filter({ hasText: "Chat history" }).last();
await expect(userMessage).toBeVisible({ timeout: 30_000 });
await expect(userMessage).not.toContainText("Source agent:");
});
test("forks an assistant turn into New Workspace and keeps the attachment across host changes", async ({
page,
seedForkWorkspace,
}) => {
await seedSavedSettingsHosts(page, [
{
serverId: getServerId(),
label: "localhost",
endpoint: `127.0.0.1:${getE2EDaemonPort()}`,
},
{
serverId: "secondary-assistant-fork-host",
label: "Secondary host",
// The host does not need to be reachable; this pins that the draft-scoped
// attachment survives changing the selected target host.
endpoint: "127.0.0.1:9",
},
]);
const session = await seedForkWorkspace({
repoPrefix: "assistant-fork-workspace-",
title: "Assistant fork workspace",
initialPrompt: "emit 1 coalesced agent stream updates for assistant fork new workspace.",
model: "ten-second-stream",
});
await openAgentRoute(page, session);
await expectComposerVisible(page);
await awaitAssistantMessage(page);
await session.client.waitForFinish(session.agentId, 45_000);
await openAssistantForkMenu(page);
await page.getByTestId("assistant-fork-menu-new-workspace").click();
await expect(page).toHaveURL(/\/new\?.*draftId=/, { timeout: 30_000 });
await expectChatHistoryPill(page);
await page.getByTestId("host-picker-trigger").click();
await page
.getByTestId("new-workspace-host-picker-option-secondary-assistant-fork-host")
.click();
await expectChatHistoryPill(page);
});
test("keeps the fork attachment after the new agent receives its user message", async ({
page,
seedForkWorkspace,
}) => {
const session = await seedForkWorkspace({
repoPrefix: "assistant-fork-submit-",
title: "Assistant fork submit",
initialPrompt: "emit 1 coalesced agent stream updates for assistant fork submit.",
model: "ten-second-stream",
});
await openAgentRoute(page, session);
await expectComposerVisible(page);
await awaitAssistantMessage(page);
await session.client.waitForFinish(session.agentId, 45_000);
await openAssistantForkMenu(page);
await page.getByTestId("assistant-fork-menu-new-workspace").click();
await expectChatHistoryPill(page);
await submitNewWorkspaceEmpty(page);
const userMessage = page.getByTestId("user-message").filter({ hasText: "Chat history" }).last();
await expect(userMessage).toBeVisible({ timeout: 30_000 });
await expect(userMessage).not.toContainText("Source agent:");
});
});

View File

@@ -0,0 +1,131 @@
import { expect, test, type Page } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import {
expectNoBranchSwitcherInWorkspaceHeader,
expectWorkspaceBranch,
openChangesPanel,
switchBranchFromChangesPanel,
} from "./helpers/branch-switcher";
import { seedWorkspace } from "./helpers/seed-client";
import { getServerId } from "./helpers/server-id";
import { readWorktreeBranchInfo } from "./helpers/workspace";
import { switchWorkspaceViaSidebar, waitForSidebarHydration } from "./helpers/workspace-ui";
async function renameWorkspaceViaSidebar(
page: Page,
input: { workspaceId: string; title: string },
): Promise<void> {
const serverId = getServerId();
const row = page.getByTestId(`sidebar-workspace-row-${serverId}:${input.workspaceId}`);
await expect(row).toBeVisible({ timeout: 30_000 });
await row.hover();
const kebab = page.getByTestId(`sidebar-workspace-kebab-${serverId}:${input.workspaceId}`);
await expect(kebab).toBeVisible({ timeout: 10_000 });
await kebab.click();
const renameItem = page.getByTestId(
`sidebar-workspace-menu-rename-${serverId}:${input.workspaceId}`,
);
await expect(renameItem).toBeVisible({ timeout: 10_000 });
await renameItem.click();
const modalPrefix = `sidebar-workspace-rename-modal-${serverId}:${input.workspaceId}`;
const renameInput = page.getByTestId(`${modalPrefix}-input`);
await expect(renameInput).toBeVisible({ timeout: 10_000 });
await renameInput.fill(input.title);
await page.getByTestId(`${modalPrefix}-submit`).click();
await expect(renameInput).toHaveCount(0, { timeout: 15_000 });
}
test.describe("Branch switcher", () => {
// The first test after a spec-file switch can fail while the shared daemon
// releases stale sessions from the previous spec; one retry stabilizes it.
test.describe.configure({ retries: 1 });
test("switches the workspace branch from the git diff panel for an opaque workspace id", async ({
page,
}) => {
test.setTimeout(90_000);
const serverId = getServerId();
const workspace = await seedWorkspace({
repoPrefix: "branch-switch-",
repo: { branches: ["main", "dev"] },
});
try {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: workspace.workspaceId });
await openChangesPanel(page);
await expectWorkspaceBranch(page, "main");
await switchBranchFromChangesPanel(page, { from: "main", to: "dev" });
await expectWorkspaceBranch(page, "dev");
await expect
.poll(
async () =>
(await readWorktreeBranchInfo({ worktreePath: workspace.repoPath })).currentBranch,
{ timeout: 30_000 },
)
.toBe("dev");
} finally {
await workspace.cleanup();
}
});
test("a custom workspace title stays in the header while the diff panel switches the real branch", async ({
page,
}) => {
test.setTimeout(90_000);
const serverId = getServerId();
const workspace = await seedWorkspace({
repoPrefix: "branch-coherence-",
repo: { branches: ["main", "dev"] },
});
try {
expect(workspace.workspaceName).toBe("main");
await gotoAppShell(page);
await waitForSidebarHydration(page);
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: workspace.workspaceId });
const customTitle = "Payments Refactor";
await renameWorkspaceViaSidebar(page, {
workspaceId: workspace.workspaceId,
title: customTitle,
});
// The header shows the custom title verbatim (a plain static title), never a
// branch name, and the branch switcher does not live in the header.
const headerTitle = page
.getByTestId("workspace-header-title")
.filter({ visible: true })
.first();
await expect(headerTitle).toHaveText(customTitle, { timeout: 30_000 });
await expectNoBranchSwitcherInWorkspaceHeader(page);
// The diff panel's switcher tracks the real branch ("main"), not the title,
// and switching it checks out the real branch on disk.
await openChangesPanel(page);
await expectWorkspaceBranch(page, "main");
await switchBranchFromChangesPanel(page, { from: "main", to: "dev" });
await expectWorkspaceBranch(page, "dev");
// The custom title is unaffected by the branch switch.
await expect(headerTitle).toHaveText(customTitle, { timeout: 30_000 });
await expect
.poll(
async () =>
(await readWorktreeBranchInfo({ worktreePath: workspace.repoPath })).currentBranch,
{ timeout: 30_000 },
)
.toBe("dev");
} finally {
await workspace.cleanup();
}
});
});

View File

@@ -0,0 +1,72 @@
import { randomUUID } from "node:crypto";
import { expect } from "@playwright/test";
import { test } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import { createIdleAgent } from "./helpers/archive-tab";
import { openCommandCenter } from "./helpers/command-center";
import { addOfflineHostAndReload } from "./helpers/hosts";
import { expectAgentTabActive } from "./helpers/launcher";
import { seedWorkspace } from "./helpers/seed-client";
import { getServerId } from "./helpers/server-id";
const PRIMARY_HOST_LABEL = "Primary Host";
const SECONDARY_HOST_ID = "host-command-center-secondary";
test.describe("Command center host labels", () => {
test.describe.configure({ timeout: 180_000 });
test("agent results show the host they live on when more than one host exists", async ({
page,
}) => {
const seeded = await seedWorkspace({ repoPrefix: "command-center-host-" });
const title = `cc-host-${randomUUID().slice(0, 8)}`;
try {
const agent = await createIdleAgent(seeded.client, {
cwd: seeded.repoPath,
workspaceId: seeded.workspaceId,
title,
});
// A second (offline) host makes the view multi-host, which is when the host label earns its space.
await gotoAppShell(page);
await addOfflineHostAndReload(page, {
serverId: SECONDARY_HOST_ID,
label: "Secondary Host",
primaryLabel: PRIMARY_HOST_LABEL,
});
const panel = await openCommandCenter(page);
// The shared daemon may carry agents from other specs, so target this agent by its id.
const row = panel.getByTestId(`command-center-agent-${getServerId()}:${agent.id}`);
await expect(row).toBeVisible({ timeout: 30_000 });
await expect(row).toContainText(title);
await expect(row).toContainText(PRIMARY_HOST_LABEL);
} finally {
await seeded.cleanup();
}
});
test("selecting an agent result opens its workspace tab", async ({ page }) => {
const seeded = await seedWorkspace({ repoPrefix: "command-center-agent-navigation-" });
const title = `cc-navigation-${randomUUID().slice(0, 8)}`;
try {
const agent = await createIdleAgent(seeded.client, {
cwd: seeded.repoPath,
workspaceId: seeded.workspaceId,
title,
});
await gotoAppShell(page);
const panel = await openCommandCenter(page);
await panel.getByTestId("command-center-input").fill(title);
await page.keyboard.press("Enter");
await expectAgentTabActive(page, agent.id);
} finally {
await seeded.cleanup();
}
});
});

View File

@@ -0,0 +1,106 @@
import { execFileSync } from "node:child_process";
import { expect } from "@playwright/test";
import { test } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import { createIdleAgent } from "./helpers/archive-tab";
import { openCommandCenter } from "./helpers/command-center";
import { addOfflineHostAndReload } from "./helpers/hosts";
import { expectAppRoute } from "./helpers/route-assertions";
import { seedWorkspace } from "./helpers/seed-client";
import { getServerId } from "./helpers/server-id";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
const PRIMARY_HOST_LABEL = "Primary Host";
const SECONDARY_HOST_ID = "host-command-center-workspaces-secondary";
const WORKSPACE_TITLE = "Payments Refactor";
const WORKSPACE_BRANCH = "feature/cmd-k-workspaces";
const AGENT_TITLE = "Fix checkout retries";
test.describe("Command center workspaces", () => {
test.describe.configure({ timeout: 180_000 });
test("workspace results show their title, host, and branch and open the workspace", async ({
page,
}) => {
const seeded = await seedWorkspace({
repoPrefix: "command-center-workspace-",
title: WORKSPACE_TITLE,
});
try {
execFileSync("git", ["checkout", "-b", WORKSPACE_BRANCH], {
cwd: seeded.repoPath,
stdio: "ignore",
});
const refreshed = await seeded.client.checkoutRefresh(seeded.repoPath);
if (!refreshed.success) {
throw new Error(`Failed to refresh checkout: ${JSON.stringify(refreshed.error)}`);
}
const agent = await createIdleAgent(seeded.client, {
cwd: seeded.repoPath,
workspaceId: seeded.workspaceId,
title: AGENT_TITLE,
});
await gotoAppShell(page);
await addOfflineHostAndReload(page, {
serverId: SECONDARY_HOST_ID,
label: "Secondary Host",
primaryLabel: PRIMARY_HOST_LABEL,
});
const panel = await openCommandCenter(page);
const row = panel.getByTestId(
`command-center-workspace-${getServerId()}:${seeded.workspaceId}`,
);
await expect(row).toBeVisible({ timeout: 30_000 });
await expect(row).toContainText(WORKSPACE_TITLE);
await expect(row).toContainText(PRIMARY_HOST_LABEL);
await expect(row).toContainText(WORKSPACE_BRANCH);
const agentRow = panel.getByTestId(`command-center-agent-${getServerId()}:${agent.id}`);
await expect(agentRow).toContainText(AGENT_TITLE);
await expect(agentRow).toContainText(PRIMARY_HOST_LABEL);
await expect(agentRow).toContainText(WORKSPACE_TITLE);
await expect(agentRow).not.toContainText(seeded.repoPath);
const workspaceSectionTop = await panel
.getByText("Workspaces", { exact: true })
.evaluate((element) => element.getBoundingClientRect().top);
const agentSectionTop = await panel
.getByText("Agents", { exact: true })
.evaluate((element) => element.getBoundingClientRect().top);
expect(workspaceSectionTop).toBeLessThan(agentSectionTop);
const input = panel.getByTestId("command-center-input");
await input.fill(PRIMARY_HOST_LABEL);
await expect(row).toBeVisible();
await expect(agentRow).toBeVisible();
await input.fill(WORKSPACE_BRANCH);
await expect(row).toBeVisible();
await expect(agentRow).not.toBeVisible();
await input.fill(WORKSPACE_TITLE);
await expect(row).toBeVisible();
await expect(agentRow).toBeVisible();
await input.fill(seeded.repoPath);
await expect(agentRow).toBeVisible();
await expect(row).not.toBeVisible();
await input.fill(AGENT_TITLE);
await expect(agentRow).toBeVisible();
await expect(row).not.toBeVisible();
await input.fill(WORKSPACE_TITLE);
await page.keyboard.press("Enter");
await expectAppRoute(page, buildHostWorkspaceRoute(getServerId(), seeded.workspaceId), {
timeout: 30_000,
});
} finally {
await seeded.cleanup();
}
});
});

View File

@@ -0,0 +1,72 @@
import { execFileSync } from "node:child_process";
import { writeFile } from "node:fs/promises";
import path from "node:path";
import { test, expect } from "./fixtures";
const COMMIT_SUBJECT = "Show commit timestamps";
test("commit history shows dates and shares diff layout preferences", async ({
page,
withWorkspace,
}) => {
const workspace = await withWorkspace({ prefix: "commit-diff-panel-" });
await createFeatureCommit(workspace.repoPath);
await page.setViewportSize({ width: 1400, height: 900 });
await workspace.navigateTo();
await page.getByRole("button", { name: "Open explorer" }).click();
const commitsSection = page.getByRole("button", { name: /Commits/i });
await expect(commitsSection).toBeVisible({ timeout: 30_000 });
await commitsSection.click();
const commitRow = page.locator('[data-testid^="commit-row-"]').filter({
hasText: COMMIT_SUBJECT,
});
await expect(commitRow).toContainText(COMMIT_SUBJECT, { timeout: 30_000 });
await expect(commitRow).toContainText("Jan 15");
await commitRow.click();
const panel = page.getByTestId("commit-diff-panel").filter({ visible: true });
await expect(panel.getByTestId("commit-diff-toolbar")).toBeVisible({ timeout: 30_000 });
await expect(panel.getByTestId("commit-diff-layout-unified")).toHaveAttribute(
"aria-selected",
"true",
);
await expect(panel.getByTestId("diff-code-row-0")).toBeVisible({ timeout: 30_000 });
await panel.getByTestId("commit-diff-layout-split").click();
await expect(panel.getByTestId("commit-diff-layout-split")).toHaveAttribute(
"aria-selected",
"true",
);
await expect(panel.getByTestId("diff-code-row-0")).toHaveCount(0);
await expect(panel.getByTestId("diff-file-0-body")).toBeVisible();
await page.getByTestId(/^workspace-commit-diff-close-/).click();
await expect(panel).toHaveCount(0);
await commitRow.click();
await expect(panel.getByTestId("commit-diff-layout-split")).toHaveAttribute(
"aria-selected",
"true",
{ timeout: 30_000 },
);
await page.setViewportSize({ width: 480, height: 900 });
await expect(panel.getByTestId("commit-diff-toolbar")).toHaveCount(0);
await expect(panel.getByTestId("diff-code-row-0")).toBeVisible();
});
async function createFeatureCommit(repoPath: string): Promise<void> {
execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoPath, stdio: "ignore" });
await writeFile(path.join(repoPath, "feature.txt"), "before\nafter\n");
execFileSync("git", ["add", "feature.txt"], { cwd: repoPath, stdio: "ignore" });
execFileSync("git", ["commit", "-m", COMMIT_SUBJECT], {
cwd: repoPath,
stdio: "ignore",
env: {
...process.env,
GIT_AUTHOR_DATE: "2020-01-15T12:00:00Z",
GIT_COMMITTER_DATE: "2020-01-15T12:00:00Z",
},
});
}

View File

@@ -16,6 +16,7 @@ import {
expectComposerEditable,
expectAttachButtonDisabled,
fillComposerDraft,
dropFileOnComposer,
sendDraftToQueue,
expectQueuedMessageButton,
startRunningMockAgent,
@@ -36,6 +37,11 @@ const MINIMAL_PNG = Buffer.from(
);
const TEST_IMAGE = { name: "test.png", mimeType: "image/png", buffer: MINIMAL_PNG };
const TEST_JSON = {
name: "config.json",
mimeType: "application/json",
buffer: Buffer.from(JSON.stringify({ composer: "drop" })),
};
test.describe("Composer attachments", () => {
test("Plus menu shows image and GitHub options", async ({ page, withWorkspace }) => {
@@ -172,6 +178,47 @@ test.describe("Composer attachments", () => {
await expectAttachmentPill(page, "composer-image-attachment-pill");
});
test("dropped JSON file renders as a file attachment in active chat", async ({
page,
withWorkspace,
}) => {
test.setTimeout(60_000);
const workspace = await withWorkspace({ prefix: "attach-drop-json-" });
await workspace.navigateTo();
await clickNewChat(page);
await expectComposerVisible(page);
await dropFileOnComposer(page, TEST_JSON);
await expectAttachmentPill(page, "composer-file-attachment-pill");
});
test("dropped JSON file renders as a file attachment in New Workspace", async ({ page }) => {
test.setTimeout(120_000);
const workspace = await seedWorkspace({ repoPrefix: "attach-drop-new-workspace-" });
try {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await switchWorkspaceViaSidebar({
page,
serverId: getServerId(),
workspaceId: workspace.workspaceId,
});
await openNewWorkspaceComposer(page, {
projectKey: workspace.projectId,
projectDisplayName: workspace.projectDisplayName,
});
await dropFileOnComposer(page, TEST_JSON);
await expectAttachmentPill(page, "composer-file-attachment-pill");
} finally {
await workspace.cleanup();
}
});
test("clicking the X on an image pill removes it", async ({ page, withWorkspace }) => {
test.setTimeout(60_000);
const workspace = await withWorkspace({ prefix: "attach-remove-" });
@@ -193,7 +240,7 @@ test.describe("Composer attachments", () => {
page,
}) => {
test.setTimeout(120_000);
const { client, repo } = await startRunningMockAgent(page, {
const agent = await startRunningMockAgent(page, {
prefix: "attach-queue-",
model: "one-minute-stream",
prompt: "Stay running for queue test.",
@@ -205,8 +252,7 @@ test.describe("Composer attachments", () => {
await expectQueuedMessageButton(page);
await expectComposerDraft(page, "");
} finally {
await client.close();
await repo.cleanup();
await agent.cleanup();
}
});
@@ -214,7 +260,7 @@ test.describe("Composer attachments", () => {
page,
}) => {
test.setTimeout(120_000);
const { client, repo } = await startRunningMockAgent(page, {
const agent = await startRunningMockAgent(page, {
prefix: "attach-interrupt-",
model: "ten-second-stream",
prompt: "Stay running for interrupt test.",
@@ -226,8 +272,7 @@ test.describe("Composer attachments", () => {
await expectAgentIdle(page, 15_000);
await expectComposerDraft(page, "preserve me");
} finally {
await client.close();
await repo.cleanup();
await agent.cleanup();
}
});
@@ -244,7 +289,7 @@ test.describe("Composer attachments", () => {
await switchWorkspaceViaSidebar({
page,
serverId,
targetWorkspacePath: workspace.workspaceId,
workspaceId: workspace.workspaceId,
});
await openNewWorkspaceComposer(page, {

View File

@@ -1,8 +1,14 @@
import { expect, test, type Page } from "./fixtures";
import { composerLocator, expectComposerVisible } from "./helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
import { expectWorkspaceTabVisible } from "./helpers/archive-tab";
import {
openAgentRoute,
seedMockAgentWorkspace,
type MockAgentWorkspace,
} from "./helpers/mock-agent";
import { expectWorkspaceTabVisible, openSessions } from "./helpers/archive-tab";
import { daemonWsRoutePattern } from "./helpers/daemon-port";
import { getServerId } from "./helpers/server-id";
import { switchWorkspaceViaSidebar } from "./helpers/workspace-ui";
const TEST_COMMANDS = [
{
@@ -142,6 +148,50 @@ async function installListCommandsStub(page: Page): Promise<void> {
});
}
async function openAppWideNewWorkspace(page: Page): Promise<void> {
await page.getByTestId("sidebar-global-new-workspace").first().click();
await page.waitForURL((url) => url.pathname === "/new", { timeout: 30_000 });
}
async function openSettingsThenBackToWorkspace(page: Page): Promise<void> {
await page.getByTestId("sidebar-settings").filter({ visible: true }).first().click();
await expect(page).toHaveURL(/\/settings\/general$/, { timeout: 30_000 });
await page.getByTestId("settings-back-to-workspace").click();
await page.waitForURL((url) => url.pathname.includes("/workspace/"), { timeout: 30_000 });
}
async function expectSingleCurrentWorkspaceDeckEntry(
page: Page,
input: { expectedDeckEntryCount: number; serverId: string; workspaceId: string },
): Promise<void> {
const summary = await page
.locator('[data-testid^="workspace-deck-entry-"]')
.evaluateAll((elements, target) => {
const currentTestId = `workspace-deck-entry-${target.serverId}:${target.workspaceId}`;
const entries = elements.map((element) => {
const rect = element.getBoundingClientRect();
return {
testId: element.getAttribute("data-testid"),
hasLayout: rect.width > 0 && rect.height > 0,
};
});
const currentEntries = entries.filter((entry) => entry.testId === currentTestId);
return {
totalDeckEntryCount: entries.length,
currentWorkspaceEntryCount: currentEntries.length,
visibleCurrentWorkspaceEntryCount: currentEntries.filter((entry) => entry.hasLayout).length,
hiddenCurrentWorkspaceEntryCount: currentEntries.filter((entry) => !entry.hasLayout).length,
};
}, input);
expect(summary).toEqual({
totalDeckEntryCount: input.expectedDeckEntryCount,
currentWorkspaceEntryCount: 1,
visibleCurrentWorkspaceEntryCount: 1,
hiddenCurrentWorkspaceEntryCount: 0,
});
}
async function openReadyMockAgent(
page: Page,
options?: { expectWorkspaceTab?: boolean },
@@ -308,6 +358,82 @@ function expectPopoverDoesNotDisappearAfterFirstVisible(frames: PopoverFrame[]):
}
test.describe("Composer autocomplete", () => {
test("stays visible after returning from app-wide routes", async ({ page }) => {
await installListCommandsStub(page);
const serverId = getServerId();
const sessions: MockAgentWorkspace[] = [];
try {
sessions.push(
await seedMockAgentWorkspace({
repoPrefix: "autocomplete-new-route-a-",
title: "Autocomplete new route A",
}),
);
sessions.push(
await seedMockAgentWorkspace({
repoPrefix: "autocomplete-new-route-b-",
title: "Autocomplete new route B",
}),
);
sessions.push(
await seedMockAgentWorkspace({
repoPrefix: "autocomplete-new-route-c-",
title: "Autocomplete new route C",
}),
);
const [first, second, third] = sessions;
await openAgentRoute(page, first);
await expectComposerVisible(page, { timeout: 30_000 });
await openAppWideNewWorkspace(page);
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: second.workspaceId });
await expectComposerVisible(page, { timeout: 30_000 });
await expectSingleCurrentWorkspaceDeckEntry(page, {
expectedDeckEntryCount: 2,
serverId,
workspaceId: second.workspaceId,
});
await openSettingsThenBackToWorkspace(page);
await expectComposerVisible(page, { timeout: 30_000 });
await expectSingleCurrentWorkspaceDeckEntry(page, {
expectedDeckEntryCount: 2,
serverId,
workspaceId: second.workspaceId,
});
await openSessions(page);
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: third.workspaceId });
await expectComposerVisible(page, { timeout: 30_000 });
await expectSingleCurrentWorkspaceDeckEntry(page, {
expectedDeckEntryCount: sessions.length,
serverId,
workspaceId: third.workspaceId,
});
await openAppWideNewWorkspace(page);
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: first.workspaceId });
await expectComposerVisible(page, { timeout: 30_000 });
await expectSingleCurrentWorkspaceDeckEntry(page, {
expectedDeckEntryCount: sessions.length,
serverId,
workspaceId: first.workspaceId,
});
await composerLocator(page).fill("/");
const popover = page
.getByTestId("composer-autocomplete-popover")
.filter({ hasText: "/help", visible: true })
.first();
await expect(popover).toBeInViewport({ timeout: 30_000 });
} finally {
await Promise.allSettled(sessions.map((session) => session.cleanup()));
}
});
test("does not flash at the wrong position on the first slash command paint", async ({
page,
}) => {

View File

@@ -0,0 +1,174 @@
import { execFileSync } from "node:child_process";
import {
cpSync,
mkdirSync,
mkdtempSync,
readFileSync,
realpathSync,
rmSync,
writeFileSync,
} from "node:fs";
import { createRequire } from "node:module";
import os from "node:os";
import path from "node:path";
import {
_electron as electron,
expect,
test,
type ElectronApplication,
type Page,
} from "@playwright/test";
test.skip(process.env.E2E_DESKTOP_RUNTIME !== "1", "requires the real Electron product runtime");
test.setTimeout(180_000);
const repoRoot = path.resolve(__dirname, "../../..");
const nodeRequire = createRequire(path.join(repoRoot, "packages", "desktop", "package.json"));
const importPackageRoot = path.resolve(path.dirname(nodeRequire.resolve("@getpaseo/import")), "..");
let installation: Awaited<ReturnType<typeof createConductorInstallation>>;
let electronApp: ElectronApplication;
test.beforeAll(async () => {
installation = await createConductorInstallation();
});
test.afterAll(async () => {
await electronApp?.close();
rmSync(installation.root, { recursive: true, force: true });
});
test("imports from the product Integrations row and renders success, failure, and eligibility", async () => {
electronApp = await launchProduct();
const page = await electronApp.firstWindow();
await openIntegrations(page);
await expect(page.getByRole("button", { name: "Enable built-in daemon" })).toHaveCount(0);
await openImport(page);
await expect(
page.getByText("Paseo will register valid repositories", { exact: false }),
).toBeVisible();
await page.getByTestId("import-confirm").click();
await expect(page.getByTestId("import-result")).toHaveText("Import complete.");
await expect(page.getByTestId("import-output")).toContainText(
`Registered project ${installation.repo}.`,
);
await expect(page.getByTestId("import-output")).toContainText("Import summary:");
await page.getByTestId("import-done").click();
writeFileSync(installation.databasePath, "not a sqlite database");
await openImport(page);
await page.getByTestId("import-confirm").click();
await expect(page.getByTestId("import-result")).toHaveText("Import failed.");
await expect(page.getByTestId("import-output")).toContainText("ERROR:");
await page.getByTestId("import-done").click();
await stopImportHost();
await page.getByRole("button", { name: "General", exact: true }).click();
await page.getByRole("button", { name: "Integrations", exact: true }).click();
const row = page.getByTestId("conductor-import-row");
await expect(row).toContainText("Start the Desktop-managed host before importing.");
await expect(row.getByRole("button", { name: "Import", exact: true })).toBeDisabled();
});
async function launchProduct(): Promise<ElectronApplication> {
const metroPort = requiredEnvironment("E2E_METRO_PORT");
return electron.launch({
args: [path.join(repoRoot, "packages", "desktop", "dist", "main.js")],
env: {
...process.env,
EXPO_DEV_URL: `http://127.0.0.1:${metroPort}`,
HOME: installation.home,
PASEO_HOME: requiredEnvironment("E2E_PASEO_HOME"),
PASEO_DISABLE_SINGLE_INSTANCE_LOCK: "1",
PASEO_TEST_APP_NAME: "Paseo Import Behavior",
},
});
}
async function openIntegrations(page: Page): Promise<void> {
const settings = page.getByRole("button", { name: "Settings", exact: true });
await expect(settings).toBeVisible({ timeout: 90_000 });
await settings.click();
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
await page.getByRole("button", { name: "Integrations", exact: true }).click();
await expect(page.getByTestId("conductor-import-row")).toBeVisible();
}
async function openImport(page: Page): Promise<void> {
const button = page
.getByTestId("conductor-import-row")
.getByRole("button", { name: "Import", exact: true });
await expect(button).toBeEnabled();
await button.click();
await expect(page.getByTestId("import-sheet")).toBeVisible();
}
async function createConductorInstallation(): Promise<{
root: string;
home: string;
repo: string;
databasePath: string;
}> {
const root = realpathSync(mkdtempSync(path.join(os.tmpdir(), "paseo-product-import-")));
const home = path.join(root, "home");
const repo = path.join(root, "repo");
mkdirSync(home, { recursive: true });
execFileSync("git", ["init", "-b", "main", repo]);
execFileSync("git", ["config", "user.email", "fixture@example.com"], { cwd: repo });
execFileSync("git", ["config", "user.name", "Fixture"], { cwd: repo });
writeFileSync(path.join(repo, "README.md"), "fixture\n");
execFileSync("git", ["add", "."], { cwd: repo });
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "fixture"], {
cwd: repo,
});
const databaseDirectory = path.join(home, "Library", "Application Support", "com.conductor.app");
mkdirSync(databaseDirectory, { recursive: true });
const databasePath = path.join(databaseDirectory, "conductor.db");
cpSync(path.join(importPackageRoot, "fixtures", "conductor", "conductor.db"), databasePath);
execFileSync(
process.execPath,
[
"-e",
`const fs = require("node:fs");
const initSqlJs = require("sql.js");
(async () => {
const SQL = await initSqlJs({ locateFile: () => require.resolve("sql.js/dist/sql-wasm.wasm") });
const databasePath = process.argv[1];
const repo = process.argv[2];
const database = new SQL.Database(fs.readFileSync(databasePath));
database.run("UPDATE repos SET root_path = ? WHERE id = 'repo-current'", [repo]);
database.run("UPDATE repos SET is_hidden = 1 WHERE id <> 'repo-current'");
database.run("DELETE FROM workspaces");
fs.writeFileSync(databasePath, database.export());
database.close();
})().catch((error) => { console.error(error); process.exitCode = 1; });`,
databasePath,
repo,
],
{ cwd: repoRoot },
);
return { root, home, repo: realpathSync(repo), databasePath };
}
async function stopImportHost(): Promise<void> {
const pidFile = path.join(requiredEnvironment("E2E_PASEO_HOME"), "paseo.pid");
const pid = (JSON.parse(readFileSync(pidFile, "utf8")) as { pid: number }).pid;
process.kill(pid, "SIGTERM");
const deadline = Date.now() + 10_000;
while (Date.now() < deadline) {
try {
process.kill(pid, 0);
await new Promise((resolve) => setTimeout(resolve, 100));
} catch {
return;
}
}
throw new Error(`Import host ${pid} did not stop.`);
}
function requiredEnvironment(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`${name} is required.`);
return value;
}

View File

@@ -4,8 +4,11 @@ import { getServerId } from "./helpers/server-id";
import {
loadRealDaemonState,
injectDesktopBridge,
openDesktopAboutSettings,
openDesktopSettings,
expectUpdateBanner,
clickCheckForUpdates,
expectPendingUpdateCheckResult,
clickInstallUpdate,
expectInstallInProgress,
interceptDaemonManagementConfirmDialog,
@@ -45,6 +48,21 @@ test.describe("Desktop updates", () => {
await clickInstallUpdate(page);
await expectInstallInProgress(page);
});
test("manual check reports a found update while it downloads", async ({ page }) => {
await injectDesktopBridge(page, {
serverId: getServerId(),
updateAvailable: true,
latestVersion: "1.2.3",
updateReadyToInstall: false,
});
await gotoAppShell(page);
await openDesktopAboutSettings(page);
await clickCheckForUpdates(page);
await expectPendingUpdateCheckResult(page, "1.2.3");
});
});
test.describe("Desktop daemon management", () => {

View File

@@ -0,0 +1,573 @@
import { writeFile } from "node:fs/promises";
import path from "node:path";
import { type Page } from "@playwright/test";
import { buildHostWorkspaceRoute, buildSettingsSectionRoute } from "../src/utils/host-routes";
import { test, expect } from "./fixtures";
import { getServerId } from "./helpers/server-id";
import { connectSeedClient } from "./helpers/seed-client";
import { createTempGitRepo } from "./helpers/workspace";
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
interface DirtyWorkspace {
id: string;
}
interface CleanupTask {
run: () => Promise<void>;
}
const cleanupTasks: CleanupTask[] = [];
const APP_SETTINGS_KEY = "@paseo:app-settings";
const CHANGES_PREFERENCES_KEY = "@paseo:changes-preferences";
const BEFORE = `import { useLayoutEffect, useMemo, useRef, useState } from "react";
interface UseMountedTabSetInput {
activeTabId: string | null;
allTabIds: string[];
cap: number;
}
interface UseMountedTabSetResult {
mountedTabIds: Set<string>;
}
function createInitialMountedTabIds(input: UseMountedTabSetInput): Set<string> {
if (!input.activeTabId || !input.allTabIds.includes(input.activeTabId)) {
return new Set<string>();
}
return new Set<string>([input.activeTabId]);
}
function setsEqual(left: Set<string>, right: Set<string>): boolean {
if (left.size !== right.size) {
return false;
}
for (const value of left) {
if (!right.has(value)) {
return false;
}
}
return true;
}
export function useMountedTabSet(input: UseMountedTabSetInput): UseMountedTabSetResult {
const { activeTabId, allTabIds, cap } = input;
const allTabIdsKey = allTabIds.join("\\u0000");
const availableTabIds = useMemo(() => {
void allTabIdsKey;
return new Set(allTabIds);
}, [allTabIds, allTabIdsKey]);
const [mountedTabIds, setMountedTabIds] = useState(() => createInitialMountedTabIds(input));
const lruRef = useRef(activeTabId && allTabIds.includes(activeTabId) ? [activeTabId] : []);
useLayoutEffect(() => {
const nextLru = lruRef.current.filter((tabId) => availableTabIds.has(tabId));
if (activeTabId && availableTabIds.has(activeTabId)) {
const existingIndex = nextLru.indexOf(activeTabId);
if (existingIndex >= 0) {
nextLru.splice(existingIndex, 1);
}
nextLru.unshift(activeTabId);
}
if (nextLru.length > cap) {
nextLru.length = cap;
}
lruRef.current = nextLru;
setMountedTabIds((previousMountedTabIds) => {
const nextMountedTabIds = new Set(nextLru);
return setsEqual(previousMountedTabIds, nextMountedTabIds)
? previousMountedTabIds
: nextMountedTabIds;
});
}, [activeTabId, availableTabIds, cap]);
return { mountedTabIds };
}
`;
const AFTER = `import { useLayoutEffect, useMemo, useRef, useState } from "react";
interface UseMountedTabSetInput {
activeTabId: string | null;
allTabIds: string[];
cap: number;
}
interface UseMountedTabSetResult {
mountedTabIds: Set<string>;
}
interface DeriveRenderMountedTabIdsInput {
activeTabId: string | null;
availableTabIds: Set<string>;
cap: number;
mountedTabIds: Set<string>;
}
function createInitialMountedTabIds(input: UseMountedTabSetInput): Set<string> {
if (!input.activeTabId || !input.allTabIds.includes(input.activeTabId)) {
return new Set<string>();
}
return new Set<string>([input.activeTabId]);
}
function setsEqual(left: Set<string>, right: Set<string>): boolean {
if (left.size !== right.size) {
return false;
}
for (const value of left) {
if (!right.has(value)) {
return false;
}
}
return true;
}
function deriveRenderMountedTabIds(input: DeriveRenderMountedTabIdsInput): Set<string> {
const { activeTabId, availableTabIds, cap, mountedTabIds } = input;
if (!activeTabId || !availableTabIds.has(activeTabId) || mountedTabIds.has(activeTabId)) {
return mountedTabIds;
}
const next = new Set<string>([activeTabId]);
const maxSize = Math.max(1, cap);
for (const tabId of mountedTabIds) {
if (next.size >= maxSize) {
break;
}
if (availableTabIds.has(tabId)) {
next.add(tabId);
}
}
return next;
}
export function useMountedTabSet(input: UseMountedTabSetInput): UseMountedTabSetResult {
const { activeTabId, allTabIds, cap } = input;
const allTabIdsKey = allTabIds.join("\\u0000");
const availableTabIds = useMemo(() => {
void allTabIdsKey;
return new Set(allTabIds);
}, [allTabIds, allTabIdsKey]);
const [mountedTabIds, setMountedTabIds] = useState(() => createInitialMountedTabIds(input));
const lruRef = useRef(activeTabId && allTabIds.includes(activeTabId) ? [activeTabId] : []);
const renderMountedTabIds = useMemo(
() =>
deriveRenderMountedTabIds({
activeTabId,
availableTabIds,
cap,
mountedTabIds,
}),
[activeTabId, availableTabIds, cap, mountedTabIds],
);
useLayoutEffect(() => {
const nextLru = lruRef.current.filter((tabId) => availableTabIds.has(tabId));
if (activeTabId && availableTabIds.has(activeTabId)) {
const existingIndex = nextLru.indexOf(activeTabId);
if (existingIndex >= 0) {
nextLru.splice(existingIndex, 1);
}
nextLru.unshift(activeTabId);
}
if (nextLru.length > cap) {
nextLru.length = cap;
}
lruRef.current = nextLru;
setMountedTabIds((previousMountedTabIds) => {
const nextMountedTabIds = new Set(nextLru);
return setsEqual(previousMountedTabIds, nextMountedTabIds)
? previousMountedTabIds
: nextMountedTabIds;
});
}, [activeTabId, availableTabIds, cap]);
return { mountedTabIds: renderMountedTabIds };
}
`;
test.afterEach(async () => {
for (const task of cleanupTasks.splice(0)) {
await task.run();
}
});
test("changes diff keeps code rows aligned with the gutter", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff();
await useCodeFont(page, 9);
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
await expectDiffCodeFontSize(page, 9);
await expectVisibleDiffRowsAligned(page);
await expectDiffCodeTextAlignedWithGutterText(page, [
{
codeText: "function createInitialMountedTabIds(input: UseMountedTabSetInput)",
lineNumber: "20",
},
{ codeText: "return next;", lineNumber: "55" },
{ codeText: "useLayoutEffect(() => {", lineNumber: "78" },
]);
await expectHoverCommentButtonAlignedWithCodeLine(page, {
codeText: "function createInitialMountedTabIds(input: UseMountedTabSetInput)",
lineNumber: "20",
});
});
test("changes diff switches between flat and tree file lists", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff();
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
await expectFlatFileList(page);
await expect(page.getByTestId("changes-toggle-layout")).toBeVisible();
await expect(page.getByTestId("changes-layout-unified")).toHaveCount(0);
await expect(page.getByTestId("changes-layout-split")).toHaveCount(0);
await page.getByTestId("changes-options-menu").click();
await expect(page.getByTestId("changes-options-menu-content")).toBeVisible();
await expect(page.getByTestId("changes-toggle-whitespace")).toContainText("Hide whitespace");
await expect(page.getByTestId("changes-toggle-wrap-lines")).toContainText("Wrap long lines");
await expect(page.getByTestId("changes-refresh")).toContainText("Refresh");
await page.getByTestId("changes-toggle-whitespace").click();
await page.getByTestId("changes-options-menu").click();
await expect(page.getByTestId("changes-toggle-whitespace")).toContainText("Show whitespace");
await page.keyboard.press("Escape");
await scrollToLowerUnwrappedDiffRows(page);
await page.getByTestId("changes-toggle-view-mode").click();
await expect(page.getByTestId("diff-folder-src")).toBeVisible();
await expect(page.getByTestId("diff-file-0")).toBeVisible();
await page.getByTestId("diff-folder-src-toggle").click();
await expect(page.getByTestId("diff-file-0")).toHaveCount(0);
await page.getByTestId("changes-toggle-view-mode").click();
await expectFlatFileList(page);
});
test("changes diff keeps unwrapped gutter and code rows aligned after code size changes", async ({
page,
}) => {
const workspace = await createWorkspaceWithMountedTabDiff();
await useCodeFont(page, 12);
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
await changeCodeFontSizeFromSettings(page, 18);
await returnToWorkspaceChanges(page);
await expectStoredCodeFontSize(page, 18);
await scrollToLowerUnwrappedDiffRows(page);
await expectDiffCodeFontSize(page, 18);
await expectVisibleDiffRowsShareTypography(page);
await expectVisibleDiffRowsAligned(page);
});
async function useCodeFont(page: Page, codeFontSize: number): Promise<void> {
await page.addInitScript(
({ settingsKey, fontSize }) => {
if (localStorage.getItem(settingsKey)) {
return;
}
localStorage.setItem(
settingsKey,
JSON.stringify({
theme: "dark",
sendBehavior: "interrupt",
serviceUrlBehavior: "ask",
terminalScrollbackLines: 10_000,
uiFontFamily: "",
monoFontFamily: "",
uiFontSize: 16,
codeFontSize: fontSize,
syntaxTheme: "one",
}),
);
},
{ settingsKey: APP_SETTINGS_KEY, fontSize: codeFontSize },
);
}
async function useUnwrappedDiffLines(page: Page): Promise<void> {
await page.addInitScript(
({ preferencesKey }) => {
localStorage.setItem(
preferencesKey,
JSON.stringify({
layout: "unified",
viewMode: "flat",
wrapLines: false,
hideWhitespace: false,
}),
);
},
{ preferencesKey: CHANGES_PREFERENCES_KEY },
);
}
async function expectFlatFileList(page: Page): Promise<void> {
await expect(page.locator('[data-testid^="diff-folder-"]')).toHaveCount(0);
await expect(page.getByTestId("diff-file-0")).toContainText("use-mounted-tab-set.ts");
await expect(page.getByTestId("diff-file-0")).toContainText("src");
}
async function expectDiffCodeFontSize(page: Page, fontSize: number): Promise<void> {
await expect
.poll(async () => {
return page
.getByTestId("diff-code-text-1")
.evaluate((text) => Number.parseFloat(getComputedStyle(text).fontSize));
})
.toBe(fontSize);
}
async function expectVisibleDiffRowsAligned(page: Page): Promise<void> {
const geometry = await readVisibleDiffRowGeometry(page);
expect(geometry.maxDelta, JSON.stringify(geometry.rows, null, 2)).toBeLessThanOrEqual(1);
}
async function expectVisibleDiffRowsShareTypography(page: Page): Promise<void> {
const geometry = await readVisibleDiffRowGeometry(page);
expect(geometry.mismatchedTypography, JSON.stringify(geometry, null, 2)).toEqual([]);
}
async function readVisibleDiffRowGeometry(page: Page): Promise<{
maxDelta: number;
mismatchedTypography: { index: number; gutterLineHeight: number; codeLineHeight: number }[];
rows: {
index: number;
gutterTop: number;
codeTop: number;
delta: number;
gutterLineHeight: number;
codeLineHeight: number;
}[];
}> {
return page.locator("body").evaluate(({ ownerDocument }) => {
const root = ownerDocument.querySelector('[data-testid="diff-file-0-body"]');
if (!root) {
throw new Error("Expanded diff body is not mounted");
}
const readRows = (prefix: string, textPrefix: string) =>
Array.from(root.querySelectorAll<HTMLElement>(`[data-testid^="${prefix}"]`)).map((row) => {
const testId = row.getAttribute("data-testid") ?? "";
const index = Number(testId.slice(prefix.length));
const rect = row.getBoundingClientRect();
const text = root.querySelector<HTMLElement>(`[data-testid="${textPrefix}${index}"]`);
const lineHeight = text ? Number.parseFloat(getComputedStyle(text).lineHeight) : 0;
return { index, top: rect.top, height: rect.height, lineHeight };
});
const gutters = new Map(
readRows("diff-gutter-row-", "diff-gutter-text-").map((row) => [row.index, row]),
);
const codes = readRows("diff-code-row-", "diff-code-text-");
const rows = codes
.map((code) => {
const gutter = gutters.get(code.index);
if (!gutter) {
throw new Error(`Missing gutter row ${code.index}`);
}
return {
index: code.index,
gutterTop: gutter.top,
codeTop: code.top,
delta: Math.abs(code.top - gutter.top),
gutterLineHeight: gutter.lineHeight,
codeLineHeight: code.lineHeight,
};
})
.filter((row) => row.gutterTop >= 0 && row.codeTop >= 0);
return {
maxDelta: Math.max(...rows.map((row) => row.delta)),
mismatchedTypography: rows
.filter((row) => Math.abs(row.gutterLineHeight - row.codeLineHeight) > 0.5)
.map((row) => ({
index: row.index,
gutterLineHeight: row.gutterLineHeight,
codeLineHeight: row.codeLineHeight,
})),
rows,
};
});
}
async function createWorkspaceWithMountedTabDiff(): Promise<DirtyWorkspace> {
const repo = await createTempGitRepo("diff-row-alignment-", {
files: [{ path: "src/use-mounted-tab-set.ts", content: BEFORE }],
});
const client = await connectSeedClient();
cleanupTasks.push({
run: async () => {
await client.close().catch(() => undefined);
await repo.cleanup().catch(() => undefined);
},
});
await writeFile(path.join(repo.path, "src/use-mounted-tab-set.ts"), AFTER);
const createdWorkspace = await client.createWorkspace({
source: { kind: "directory", path: repo.path },
});
if (!createdWorkspace.workspace) {
throw new Error(createdWorkspace.error ?? `Failed to create workspace ${repo.path}`);
}
return { id: createdWorkspace.workspace.id };
}
async function openWorkspaceChanges(page: Page, workspace: DirtyWorkspace): Promise<void> {
await page.setViewportSize({ width: 1400, height: 900 });
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id));
await waitForWorkspaceTabsVisible(page);
await page.getByRole("button", { name: "Open explorer" }).click();
await openChangesInVisibleExplorer(page);
await page.getByTestId("diff-file-0").click();
await expectExpandedMountedTabDiff(page);
}
async function openChangesInVisibleExplorer(page: Page): Promise<void> {
await expect(page.getByTestId("explorer-tab-changes")).toBeVisible({ timeout: 30_000 });
await expect(page.getByText("use-mounted-tab-set.ts")).toBeVisible({ timeout: 30_000 });
}
async function expectExpandedMountedTabDiff(page: Page): Promise<void> {
await expect(page.getByTestId("diff-file-0-body")).toBeVisible({ timeout: 30_000 });
await expect(page.getByText("function createInitialMountedTabIds")).toBeVisible({
timeout: 30_000,
});
}
async function changeCodeFontSizeFromSettings(page: Page, codeFontSize: number): Promise<void> {
await page.getByTestId("sidebar-settings").click();
await expect(page).toHaveURL(new RegExp(`${buildSettingsSectionRoute("general")}|/settings$`));
await page.getByRole("button", { name: "Appearance" }).click();
await page.getByLabel("Code font size").fill(String(codeFontSize));
await page.getByLabel("Code font size").press("Enter");
await expect(page.getByLabel("Code font size")).toHaveValue(String(codeFontSize));
await expectStoredCodeFontSize(page, codeFontSize);
}
async function expectStoredCodeFontSize(page: Page, codeFontSize: number): Promise<void> {
await expect
.poll(async () => {
const raw = await page.evaluate(
(settingsKey) => localStorage.getItem(settingsKey),
APP_SETTINGS_KEY,
);
if (!raw) {
return null;
}
return (JSON.parse(raw) as { codeFontSize?: number }).codeFontSize ?? null;
})
.toBe(codeFontSize);
}
async function returnToWorkspaceChanges(page: Page): Promise<void> {
await page.getByTestId("settings-back-to-workspace").click();
await waitForWorkspaceTabsVisible(page);
await openChangesInVisibleExplorer(page);
await expectExpandedMountedTabDiff(page);
}
async function scrollToLowerUnwrappedDiffRows(page: Page): Promise<void> {
const lastRowIndex = await page.getByTestId("diff-file-0-body").evaluate((root) => {
const rows = Array.from(root.querySelectorAll<HTMLElement>('[data-testid^="diff-code-row-"]'));
if (rows.length === 0) {
throw new Error("No unwrapped code rows are mounted");
}
return Math.max(
...rows.map((row) => Number((row.getAttribute("data-testid") ?? "").slice(14))),
);
});
await page.getByTestId(`diff-code-row-${lastRowIndex}`).scrollIntoViewIfNeeded();
await expect(page.getByTestId(`diff-code-row-${lastRowIndex}`)).toBeVisible();
}
async function expectDiffCodeTextAlignedWithGutterText(
page: Page,
lines: { codeText: string; lineNumber: string }[],
): Promise<void> {
const geometries = await readDiffTextGeometry(page, lines);
for (const geometry of geometries) {
expect(geometry.codeTop, geometry.codeText).toBeCloseTo(geometry.gutterTop, 0);
}
}
async function expectHoverCommentButtonAlignedWithCodeLine(
page: Page,
line: { codeText: string; lineNumber: string },
): Promise<void> {
const target = await readDiffTextGeometry(page, [line]).then((rows) => rows[0]);
if (!target) {
throw new Error(`Could not find target line ${line.lineNumber}`);
}
await page.getByTestId(`diff-code-row-${target.index}`).hover();
const geometry = await page
.getByTestId(`diff-gutter-action-${target.index}`)
.evaluate((action, expectedCodeCenterY) => {
const rect = action.getBoundingClientRect();
return {
actionCenterY: rect.top + rect.height / 2,
codeCenterY: expectedCodeCenterY,
};
}, target.codeCenterY);
expect(geometry.actionCenterY).toBeCloseTo(geometry.codeCenterY, 0);
}
async function readDiffTextGeometry(
page: Page,
lines: { codeText: string; lineNumber: string }[],
): Promise<
{ index: number; codeText: string; codeTop: number; gutterTop: number; codeCenterY: number }[]
> {
return page.locator("body").evaluate(({ ownerDocument }, targets) => {
const root = ownerDocument.querySelector('[data-testid="explorer-content-area"]');
if (!root) {
throw new Error("Changes panel is not mounted");
}
const readIndexedElements = (prefix: string) =>
Array.from(root.querySelectorAll<HTMLElement>(`[data-testid^="${prefix}"]`)).map(
(element) => {
const testId = element.getAttribute("data-testid") ?? "";
return { index: Number(testId.slice(prefix.length)), element };
},
);
const gutterTexts = readIndexedElements("diff-gutter-text-");
const codeTexts = readIndexedElements("diff-code-text-");
return targets.map((target) => {
const gutter = gutterTexts.find(
({ element }) => (element.textContent ?? "").trim() === target.lineNumber,
);
if (!gutter) {
throw new Error(`Could not find gutter line ${target.lineNumber}`);
}
const code = codeTexts.find(
({ index, element }) =>
index === gutter.index && (element.textContent ?? "").includes(target.codeText),
);
if (!code) {
throw new Error(`Could not find code row ${target.codeText}`);
}
const codeRect = code.element.getBoundingClientRect();
const gutterRect = gutter.element.getBoundingClientRect();
return {
index: gutter.index,
codeText: target.codeText,
codeTop: codeRect.top,
gutterTop: gutterRect.top,
codeCenterY: codeRect.top + codeRect.height / 2,
};
});
}, lines);
}

View File

@@ -0,0 +1,19 @@
import { test } from "./fixtures";
import { DirectoryBootstrapScenario } from "./helpers/directory-bootstrap-scenario";
test.describe("Directory bootstrap correctness", () => {
test("connect, pushed deltas, and reconnect keep directories current without duplicate bootstraps", async ({
page,
}) => {
test.setTimeout(180_000);
const scenario = await DirectoryBootstrapScenario.open(page);
try {
await scenario.expectDirectoryStarts(1);
await scenario.stayConnectedWithoutRefetchAndApplyDeltas();
await scenario.disconnectMutateAndReconnect();
await scenario.expectVisibleReconciliationAndNavigateAgent();
} finally {
await scenario.cleanup();
}
});
});

View File

@@ -0,0 +1,241 @@
import path from "node:path";
import { existsSync } from "node:fs";
import { test, expect, type Page } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import {
addProjectFlowInput,
chooseAddProjectMethod,
openAddProjectFlow,
} from "./helpers/add-project-flow";
import { expectOpenedProject } from "./helpers/project-picker-ui";
import { connectSeedClient, seedWorkspace } from "./helpers/seed-client";
import { getServerId } from "./helpers/server-id";
import { createTempGitRepo } from "./helpers/workspace";
import { waitForSidebarHydration } from "./helpers/workspace-ui";
function workspaceRowTestId(workspaceId: string): string {
return `sidebar-workspace-row-${getServerId()}:${workspaceId}`;
}
async function archiveWorkspaceFromSidebar(page: Page, workspaceId: string): Promise<void> {
const serverId = getServerId();
const row = page.getByTestId(workspaceRowTestId(workspaceId));
await expect(row).toBeVisible({ timeout: 30_000 });
await row.hover();
const kebab = page.getByTestId(`sidebar-workspace-kebab-${serverId}:${workspaceId}`);
await expect(kebab).toBeVisible({ timeout: 10_000 });
await kebab.click();
const archiveItem = page.getByTestId(`sidebar-workspace-menu-archive-${serverId}:${workspaceId}`);
await expect(archiveItem).toBeVisible({ timeout: 10_000 });
await archiveItem.click();
}
async function removeProjectFromSidebar(page: Page, projectId: string): Promise<void> {
const projectRow = page.getByTestId(`sidebar-project-row-${projectId}`);
await expect(projectRow).toBeVisible({ timeout: 30_000 });
await projectRow.hover();
const kebab = page.getByTestId(`sidebar-project-kebab-${projectId}`);
await expect(kebab).toBeVisible({ timeout: 10_000 });
await kebab.click();
// Removing a project raises a browser confirm; accept it so the
// user-confirmed removal proceeds deterministically.
page.once("dialog", (dialog) => void dialog.accept());
const removeItem = page.getByTestId(`sidebar-project-menu-remove-${projectId}`);
await expect(removeItem).toBeVisible({ timeout: 10_000 });
await removeItem.click();
}
async function addProjectFromPicker(page: Page, projectPath: string): Promise<string> {
await openAddProjectFlow(page);
await chooseAddProjectMethod(page, "directory-search");
const input = addProjectFlowInput(page);
await input.fill(projectPath);
await page.keyboard.press("Enter");
const projectRow = page
.locator('[data-testid^="sidebar-project-row-"]')
.filter({ hasText: path.basename(projectPath) })
.first();
await expect(projectRow).toBeVisible({ timeout: 30_000 });
const testId = await projectRow.getAttribute("data-testid");
expect(testId).not.toBeNull();
return testId!.replace("sidebar-project-row-", "");
}
async function waitForSidebarProjectListReady(page: Page): Promise<void> {
await page
.locator('[data-testid="sidebar-project-empty-state"], [data-testid^="sidebar-project-row-"]')
.first()
.waitFor({ state: "visible", timeout: 60_000 });
}
test.describe("Project picker search", () => {
test("opens a project from a fuzzy directory-name search", async ({
page,
projectPickerFixture,
}) => {
await gotoAppShell(page);
await waitForSidebarProjectListReady(page);
await openAddProjectFlow(page);
await chooseAddProjectMethod(page, "directory-search");
const input = addProjectFlowInput(page);
await input.fill(projectPickerFixture.fuzzyQuery);
const suggestion = page.getByText(projectPickerFixture.projectName, { exact: false }).first();
await expect(suggestion).toBeVisible({ timeout: 30_000 });
await suggestion.click();
const projectId = await expectOpenedProject(page, projectPickerFixture.projectName);
projectPickerFixture.rememberProjectId(projectId);
});
test("shows a loading state after typing while directory suggestions are pending", async ({
page,
}) => {
await gotoAppShell(page);
await waitForSidebarProjectListReady(page);
await openAddProjectFlow(page);
await chooseAddProjectMethod(page, "directory-search");
const input = addProjectFlowInput(page);
await input.fill("paseo-loading-state-no-match");
await expect(page.getByText("Start typing a path", { exact: true })).toHaveCount(0);
await expect(page.getByText("Loading...", { exact: true })).toBeVisible();
});
});
// Projects are parents in the sidebar. Archiving the last workspace leaves the
// project row in place with a ghost "+ New workspace" child row.
test.describe("Project with no workspaces persists", () => {
test("adding a project starts with only a new-workspace child row", async ({ page }) => {
const repo = await createTempGitRepo("empty-project-add-");
const client = await connectSeedClient();
let projectId: string | null = null;
try {
await gotoAppShell(page);
await waitForSidebarProjectListReady(page);
projectId = await addProjectFromPicker(page, repo.path);
const projectRow = page.getByTestId(`sidebar-project-row-${projectId}`);
await expect(projectRow).toBeVisible({ timeout: 30_000 });
await expect(projectRow).toContainText(path.basename(repo.path));
await expect(page.getByTestId(`sidebar-workspace-list-${projectId}`)).toHaveCount(0);
const newWorkspaceRow = page.getByTestId(`sidebar-project-new-workspace-row-${projectId}`);
await expect(newWorkspaceRow).toBeVisible({ timeout: 30_000 });
await expect(newWorkspaceRow).toContainText("New workspace");
const workspaces = await client.fetchWorkspaces({ filter: { projectId } });
expect(workspaces.entries).toEqual([]);
} finally {
if (projectId) {
await client.removeProject(projectId).catch(() => undefined);
}
await client.close().catch(() => undefined);
await repo.cleanup().catch(() => undefined);
}
});
test("archiving the only workspace keeps the project row with creation still reachable", async ({
page,
}) => {
const workspace = await seedWorkspace({ repoPrefix: "empty-project-persists-" });
try {
const projectRow = page.getByTestId(`sidebar-project-row-${workspace.projectId}`);
const newWorkspaceRow = page.getByTestId(
`sidebar-project-new-workspace-row-${workspace.projectId}`,
);
const globalNewWorkspace = page.getByTestId("sidebar-global-new-workspace");
await gotoAppShell(page);
await waitForSidebarHydration(page);
await expect(projectRow).toBeVisible({ timeout: 30_000 });
const workspaceRow = page.getByTestId(workspaceRowTestId(workspace.workspaceId));
await expect(workspaceRow).toBeVisible({
timeout: 30_000,
});
await workspaceRow.click();
await expect(page.getByTestId("changes-primary-cta")).toHaveCount(0);
await archiveWorkspaceFromSidebar(page, workspace.workspaceId);
// The workspace row goes away, but its project parent stays and exposes a
// child row for creating the next workspace.
await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toHaveCount(0, {
timeout: 30_000,
});
expect(existsSync(workspace.repoPath)).toBe(true);
await expect(projectRow).toBeVisible({ timeout: 30_000 });
await expect(newWorkspaceRow).toBeVisible({ timeout: 30_000 });
await expect(newWorkspaceRow).toContainText("New workspace");
await expect(globalNewWorkspace).toBeVisible({ timeout: 30_000 });
// The project survives a reload after its last workspace is archived.
await page.reload();
await waitForSidebarHydration(page);
await expect(projectRow).toBeVisible({ timeout: 30_000 });
await expect(newWorkspaceRow).toBeVisible({ timeout: 30_000 });
} finally {
await workspace.cleanup();
}
});
});
test.describe("Project remove", () => {
test("removing a project from project actions removes it from the sidebar", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "project-remove-sidebar-" });
try {
const projectRow = page.getByTestId(`sidebar-project-row-${workspace.projectId}`);
await gotoAppShell(page);
await waitForSidebarHydration(page);
await expect(projectRow).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toBeVisible({
timeout: 30_000,
});
await removeProjectFromSidebar(page, workspace.projectId);
await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toHaveCount(0, {
timeout: 30_000,
});
await expect(projectRow).toHaveCount(0, { timeout: 30_000 });
await page.reload();
await waitForSidebarProjectListReady(page);
await expect(projectRow).toHaveCount(0, { timeout: 30_000 });
const readded = await workspace.client.addProject(workspace.repoPath);
expect(readded.error).toBeNull();
expect(readded.project).not.toBeNull();
const readdedProjectId = readded.project?.projectId ?? "";
expect(readdedProjectId).not.toBe(workspace.projectId);
expect(readded.project?.projectDisplayName).toBe(workspace.projectDisplayName);
await page.reload();
await waitForSidebarHydration(page);
await expect(projectRow).toHaveCount(0, { timeout: 30_000 });
const readdedProjectRow = page.getByTestId(`sidebar-project-row-${readdedProjectId}`);
await expect(readdedProjectRow).toBeVisible({ timeout: 30_000 });
await expect(readdedProjectRow).toContainText(workspace.projectDisplayName);
await expect(readdedProjectRow).not.toContainText(workspace.repoPath);
await expect(
page.getByTestId(`sidebar-project-new-workspace-row-${readdedProjectId}`),
).toBeVisible({ timeout: 30_000 });
} finally {
await workspace.cleanup();
}
});
});

View File

@@ -1,14 +1,33 @@
import { test as base, expect, type Page } from "@playwright/test";
import { startOutdatedDaemon, type OutdatedDaemon } from "./helpers/daemon-update";
import { getE2EDaemonPort } from "./helpers/daemon-port";
import { buildCreateAgentPreferences, buildSeededHost } from "./helpers/daemon-registry";
import {
createProjectPickerFixture,
removeProjectPickerFixture,
type ProjectPickerFixture,
} from "./helpers/project-picker-fixture";
import { connectSeedClient } from "./helpers/seed-client";
import { createWithWorkspace, type WithWorkspace } from "./helpers/with-workspace";
const EXTRA_HOSTS_KEY = "@paseo:e2e-extra-hosts";
interface TrackedProjectPickerFixture extends ProjectPickerFixture {
rememberProjectId: (projectId: string) => void;
}
// Test setup is wired through an `auto: true` fixture rather than `test.beforeEach`.
// `test.beforeEach` declared at the top level of a non-test fixture file is unreliable
// across spec-file boundaries — Playwright sometimes skips it for the first test of a
// subsequent spec when multiple specs run in the same worker. Auto fixtures run
// reliably for every test that uses this `test` object.
const test = base.extend<{ paseoE2ESetup: void; withWorkspace: WithWorkspace }>({
const test = base.extend<{
paseoE2ESetup: void;
outdatedDaemon: OutdatedDaemon;
desktopManagedOutdatedDaemon: OutdatedDaemon;
projectPickerFixture: TrackedProjectPickerFixture;
withWorkspace: WithWorkspace;
}>({
baseURL: async ({}, provide) => {
const metroPort = process.env.E2E_METRO_PORT;
if (!metroPort) {
@@ -57,7 +76,7 @@ const test = base.extend<{ paseoE2ESetup: void; withWorkspace: WithWorkspace }>(
const createAgentPreferences = buildCreateAgentPreferences(testDaemon.serverId);
await page.addInitScript(
({ daemon, preferences, seedNonce: nonce }) => {
({ daemon, preferences, seedNonce: nonce, extraHostsKey }) => {
// `addInitScript` runs on every navigation (including reloads). Some tests intentionally
// override storage and reload; they can opt out of seeding for the *next* navigation by
// setting this flag before the reload.
@@ -73,12 +92,20 @@ const test = base.extend<{ paseoE2ESetup: void; withWorkspace: WithWorkspace }>(
localStorage.setItem("@paseo:e2e", "1");
localStorage.setItem("@paseo:e2e-seed-nonce", nonce);
const rawExtraHosts = localStorage.getItem(extraHostsKey);
const extraHosts = rawExtraHosts ? JSON.parse(rawExtraHosts) : [];
// Hard-reset anything that could point to a developer's real daemon.
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon]));
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon, ...extraHosts]));
localStorage.removeItem("@paseo:settings");
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
},
{ daemon: testDaemon, preferences: createAgentPreferences, seedNonce },
{
daemon: testDaemon,
preferences: createAgentPreferences,
seedNonce,
extraHostsKey: EXTRA_HOSTS_KEY,
},
);
await provide();
@@ -92,6 +119,40 @@ const test = base.extend<{ paseoE2ESetup: void; withWorkspace: WithWorkspace }>(
},
{ auto: true },
],
outdatedDaemon: async ({}, provide) => {
const daemon = await startOutdatedDaemon();
await provide(daemon);
await daemon.close();
},
desktopManagedOutdatedDaemon: async ({}, provide) => {
const daemon = await startOutdatedDaemon({ desktopManaged: true });
await provide(daemon);
await daemon.close();
},
projectPickerFixture: async ({}, provide) => {
const resource = await createProjectPickerFixture();
const { fixture } = resource;
let projectId: string | null = null;
try {
await provide({
...fixture,
rememberProjectId: (openedProjectId) => {
projectId = openedProjectId;
},
});
} finally {
try {
const client = await connectSeedClient();
try {
await removeProjectPickerFixture(client, fixture, projectId);
} finally {
await client.close();
}
} finally {
await resource.removeDirectory();
}
}
},
withWorkspace: async ({ page }, provide) => {
const handle = createWithWorkspace(page);
await provide(handle.withWorkspace);

View File

@@ -1,14 +1,20 @@
import { spawn, type ChildProcess, execFileSync, execSync } from "node:child_process";
import { randomUUID } from "node:crypto";
import { spawn, type ChildProcess, execSync } from "node:child_process";
import { existsSync } from "node:fs";
import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises";
import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import net from "node:net";
import { Buffer } from "node:buffer";
import dotenv from "dotenv";
import { loadDaemonClientConstructor } from "./helpers/daemon-client-loader";
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./helpers/node-ws-factory";
import { forkPaseoHomeMetadata, resolvePaseoHomePath } from "./helpers/paseo-home-fork";
import { withDisabledE2ESpeechEnv } from "./helpers/speech-env";
interface WaitForServerOptions {
const wranglerCliPath = path.resolve(__dirname, "../node_modules/wrangler/bin/wrangler.js");
export interface WaitForServerOptions {
host?: string;
timeoutMs?: number;
label: string;
@@ -16,11 +22,13 @@ interface WaitForServerOptions {
getRecentOutput?: () => string;
}
type ServerProbe = (host: string, port: number) => Promise<void>;
async function getAvailablePort(): Promise<number> {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.once("error", reject);
server.listen(0, () => {
server.listen(0, "127.0.0.1", () => {
const address = server.address();
if (!address || typeof address === "string") {
server.close(() => reject(new Error("Failed to acquire port")));
@@ -31,6 +39,14 @@ async function getAvailablePort(): Promise<number> {
});
}
const RESERVED_LOCAL_PORTS = new Set([
// Default developer daemon.
6767,
// OpenCode's default local server port. Some provider probes can spawn it
// during daemon startup, so the E2E daemon must not choose the same port.
61680,
]);
function createLineBuffer(maxLines = 120): { add: (line: string) => void; dump: () => string } {
const lines: string[] = [];
return {
@@ -61,7 +77,25 @@ function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function waitForServer(port: number, options: WaitForServerOptions): Promise<void> {
async function connectToServer(host: string, port: number): Promise<void> {
await new Promise<void>((resolve, reject) => {
const socket = net.connect(port, host, () => {
socket.end();
resolve();
});
socket.setTimeout(1000, () => {
socket.destroy();
reject(new Error(`Connection timed out to ${host}:${port}`));
});
socket.on("error", reject);
});
}
async function waitForServer(
port: number,
options: WaitForServerOptions,
probe: ServerProbe = connectToServer,
): Promise<void> {
const { host = "127.0.0.1", timeoutMs = 15000, label, childProcess, getRecentOutput } = options;
const start = Date.now();
let lastConnectionError: unknown = null;
@@ -75,17 +109,7 @@ async function waitForServer(port: number, options: WaitForServerOptions): Promi
}
try {
await new Promise<void>((resolve, reject) => {
const socket = net.connect(port, host, () => {
socket.end();
resolve();
});
socket.setTimeout(1000, () => {
socket.destroy();
reject(new Error(`Connection timed out to ${host}:${port}`));
});
socket.on("error", reject);
});
await probe(host, port);
return;
} catch (error) {
lastConnectionError = error;
@@ -102,6 +126,22 @@ async function waitForServer(port: number, options: WaitForServerOptions): Promi
);
}
async function probeMetro(host: string, port: number): Promise<void> {
const response = await fetch(`http://${host}:${port}/status`, {
signal: AbortSignal.timeout(1000),
});
const body = (await response.text()).trim();
if (response.status !== 200 || body !== "packager-status:running") {
throw new Error(
`Expected Metro status on ${host}:${port}, received HTTP ${response.status}: ${JSON.stringify(body.slice(0, 200))}`,
);
}
}
export async function waitForMetro(port: number, options: WaitForServerOptions): Promise<void> {
await waitForServer(port, options, probeMetro);
}
function parseRelayStartupFailure(line: string): string | null {
const clean = stripAnsi(line);
if (/Address already in use/i.test(clean)) {
@@ -143,6 +183,67 @@ async function stopProcess(child: ChildProcess | null): Promise<void> {
});
}
function isProcessRunning(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
async function readSupervisorPidLock(home: string): Promise<number | null> {
try {
const content = await readFile(path.join(home, "paseo.pid"), "utf8");
const parsed = JSON.parse(content) as { pid?: unknown };
return typeof parsed.pid === "number" ? parsed.pid : null;
} catch {
return null;
}
}
async function stopProcessByPid(pid: number): Promise<void> {
if (!isProcessRunning(pid)) {
return;
}
try {
process.kill(pid, "SIGTERM");
} catch {
return;
}
const deadline = Date.now() + 5000;
while (Date.now() < deadline) {
if (!isProcessRunning(pid)) {
return;
}
await sleep(100);
}
if (isProcessRunning(pid)) {
try {
process.kill(pid, "SIGKILL");
} catch {
return;
}
}
}
async function stopCurrentDaemonFromPidLock(): Promise<void> {
if (!paseoHome) {
return;
}
if (process.env.E2E_DAEMON_PORT === "6767") {
throw new Error("Refusing to clean up daemon PID lock for developer daemon port 6767.");
}
const pid = await readSupervisorPidLock(paseoHome);
if (pid === null) {
return;
}
await stopProcessByPid(pid);
}
function summarizeOpenAiErrorBody(body: string): string {
const trimmed = body.trim();
if (!trimmed) {
@@ -188,7 +289,7 @@ async function isOpenAiApiKeyUsable(apiKey: string | undefined): Promise<boolean
let daemonProcess: ChildProcess | null = null;
let metroProcess: ChildProcess | null = null;
let paseoHome: string | null = null;
let fakeToolBinDir: string | null = null;
let fakeEditorBinDir: string | null = null;
let relayProcess: ChildProcess | null = null;
function resolveOptionalPaseoHomeEnv(value: string | undefined): string | null {
@@ -209,81 +310,24 @@ interface OfferPayload {
relay: { endpoint: string };
}
async function createFakeToolBin(): Promise<string> {
const binDir = await mkdtemp(path.join(tmpdir(), "paseo-e2e-tool-bin-"));
const ghPath = path.join(binDir, "gh");
await writeFile(
ghPath,
`#!/usr/bin/env node
const { spawnSync } = require("child_process");
const fs = require("fs");
const path = require("path");
const args = process.argv.slice(2);
function findRealGh() {
const fakeBinDir = __dirname;
for (const dir of (process.env.PATH || "").split(path.delimiter)) {
if (dir === fakeBinDir) continue;
const candidate = path.join(dir, "gh");
try { fs.accessSync(candidate, fs.constants.X_OK); return candidate; } catch {}
}
return null;
interface DaemonClientConfig {
url: string;
clientId: string;
clientType: "cli";
webSocketFactory: NodeWebSocketFactory;
}
function forwardToRealGh() {
const realGh = findRealGh();
if (!realGh) { console.error("[fake-gh] real gh not found in PATH"); process.exit(1); }
const result = spawnSync(realGh, process.argv.slice(2), { stdio: "inherit", env: process.env });
process.exit(result.status ?? 1);
interface PairingDaemonClient {
connect(): Promise<void>;
close(): Promise<void>;
getDaemonPairingOffer(): Promise<{
relayEnabled: boolean;
url: string;
}>;
}
if (args[0] === "auth" && args[1] === "status") {
process.exit(0);
}
if (args[0] === "pr" && args[1] === "list") {
console.log(JSON.stringify([
{
number: 515,
title: "Review selected start ref",
url: "https://github.com/getpaseo/paseo/pull/515",
state: "OPEN",
body: "Fixture pull request for app e2e.",
labels: [],
baseRefName: "main",
headRefName: "feature/start-from-pr"
}
]));
process.exit(0);
}
if (args[0] === "pr" && args[1] === "view" && args[2] === "--json" && args[3]) {
const fixture = path.join(process.cwd(), ".paseo-e2e-pr.json");
if (fs.existsSync(fixture)) {
console.log(fs.readFileSync(fixture, "utf8"));
process.exit(0);
}
forwardToRealGh();
}
if (args[0] === "api" && args[1] === "graphql") {
const fixture = path.join(process.cwd(), ".paseo-e2e-timeline.json");
if (fs.existsSync(fixture)) {
console.log(fs.readFileSync(fixture, "utf8"));
process.exit(0);
}
forwardToRealGh();
}
if (args[0] === "issue" && args[1] === "list") {
console.log("[]");
process.exit(0);
}
forwardToRealGh();
`,
);
await chmod(ghPath, 0o755);
async function createFakeEditorBin(): Promise<string> {
const binDir = await mkdtemp(path.join(tmpdir(), "paseo-e2e-editor-bin-"));
const fakeEditorSource = `#!/usr/bin/env node
const fs = require("fs");
@@ -343,29 +387,29 @@ function decodeOfferFromFragmentUrl(url: string): OfferPayload {
return offer as OfferPayload;
}
function loadPairingOfferFromCli(repoRoot: string, paseoHomePath: string): OfferPayload {
const stdout = execFileSync(
process.execPath,
["--import", "tsx", "packages/cli/src/index.ts", "daemon", "pair", "--json"],
{
cwd: repoRoot,
env: {
...process.env,
PASEO_HOME: paseoHomePath,
},
encoding: "utf8",
},
);
const payload = JSON.parse(stdout) as { relayEnabled?: boolean; url?: string | null };
if (payload.relayEnabled !== true || typeof payload.url !== "string") {
throw new Error(`Unexpected daemon pair response: ${stdout}`);
async function loadPairingOfferFromDaemon(port: number): Promise<OfferPayload> {
const DaemonClient = await loadDaemonClientConstructor<DaemonClientConfig, PairingDaemonClient>();
const client = new DaemonClient({
url: `ws://127.0.0.1:${port}/ws`,
clientId: `playwright-global-setup-${randomUUID()}`,
clientType: "cli",
webSocketFactory: createNodeWebSocketFactory(),
});
await client.connect();
try {
const pairing = await client.getDaemonPairingOffer();
if (!pairing.relayEnabled || !pairing.url) {
throw new Error("Daemon returned a disabled pairing offer");
}
return decodeOfferFromFragmentUrl(pairing.url);
} finally {
await client.close().catch(() => {});
}
return decodeOfferFromFragmentUrl(payload.url);
}
async function waitForPairingOfferFromCli(args: {
repoRoot: string;
paseoHome: string;
async function waitForPairingOfferFromDaemon(args: {
port: number;
timeoutMs?: number;
}): Promise<OfferPayload> {
const timeoutMs = args.timeoutMs ?? 15000;
@@ -374,7 +418,7 @@ async function waitForPairingOfferFromCli(args: {
while (Date.now() - start < timeoutMs) {
try {
return loadPairingOfferFromCli(args.repoRoot, args.paseoHome);
return await loadPairingOfferFromDaemon(args.port);
} catch (error) {
lastError = error;
await sleep(100);
@@ -382,17 +426,12 @@ async function waitForPairingOfferFromCli(args: {
}
throw new Error(
`Timed out waiting for \`paseo daemon pair --json\` to produce a pairing offer: ${
`Timed out waiting for daemon pairing offer: ${
lastError instanceof Error ? lastError.message : String(lastError)
}`,
);
}
interface DictationConfig {
openAiUsable: boolean;
localModelsDir: string | null;
}
async function loadEnvTestFile(repoRoot: string): Promise<void> {
const envTestPath = path.join(repoRoot, ".env.test");
if (existsSync(envTestPath)) {
@@ -425,7 +464,7 @@ async function applyPaseoHomeFork(targetHome: string): Promise<void> {
}
}
async function resolveDictationConfig(): Promise<DictationConfig> {
async function logSpeechHarnessConfig(): Promise<void> {
const openAiUsable = await isOpenAiApiKeyUsable(process.env.OPENAI_API_KEY);
const defaultLocalModelsDir = path.join(
process.env.HOME ?? "",
@@ -436,23 +475,20 @@ async function resolveDictationConfig(): Promise<DictationConfig> {
const hasDefaultLocalModelsDir =
defaultLocalModelsDir.trim().length > 0 && existsSync(defaultLocalModelsDir);
// Fork PRs run without secrets and usually without local models. Don't crash
// the whole Playwright run — disable dictation/voice and let tests that need
// them gate on PASEO_DICTATION_ENABLED.
// Default app E2E does not cover speech flows. Keep speech disabled here so
// unrelated tests never start background local-model downloads.
if (!openAiUsable && !hasDefaultLocalModelsDir) {
console.warn(
"[e2e] Neither OPENAI_API_KEY nor local speech models found — running with dictation/voice disabled. " +
"[e2e] Neither OPENAI_API_KEY nor local speech models found — app E2E keeps dictation/voice disabled. " +
"Tests that require dictation should gate on PASEO_DICTATION_ENABLED.",
);
return { openAiUsable: false, localModelsDir: null };
return;
}
const dictationProvider = openAiUsable ? "openai" : "local";
const localModelsDir = dictationProvider === "local" ? defaultLocalModelsDir : null;
const speechAssets = openAiUsable ? "OpenAI" : `local models at ${defaultLocalModelsDir}`;
console.log(
`[e2e] Dictation STT provider: ${dictationProvider}${openAiUsable ? "" : " (OpenAI probe failed)"}`,
`[e2e] Speech assets available from ${speechAssets}; app E2E keeps dictation/voice disabled.`,
);
return { openAiUsable, localModelsDir };
}
interface RelayStreamState {
@@ -540,25 +576,45 @@ async function awaitRelayReady(
async function getAvailablePortExcluding(excludedPorts: Set<number>): Promise<number> {
for (;;) {
const port = await getAvailablePort();
if (!excludedPorts.has(port)) {
if (!excludedPorts.has(port) && !RESERVED_LOCAL_PORTS.has(port)) {
return port;
}
}
}
async function startRelay(excludedPorts: Set<number>): Promise<number> {
interface RelayPorts {
relayPort: number;
inspectorPort: number;
}
async function startRelay(excludedPorts: Set<number>): Promise<RelayPorts> {
const relayDir = path.resolve(__dirname, "..", "..", "relay");
const maxRelayStartupAttempts = 5;
let lastRelayStartupError: unknown = null;
for (let attempt = 1; attempt <= maxRelayStartupAttempts; attempt += 1) {
const relayPort = await getAvailablePortExcluding(excludedPorts);
const inspectorPort = await getAvailablePortExcluding(new Set([...excludedPorts, relayPort]));
const buffer = createLineBuffer();
const state: RelayStreamState = { failureLine: null, readyForSelectedPort: false };
relayProcess = spawn(
"npx",
["wrangler", "dev", "--local", "--ip", "127.0.0.1", "--port", String(relayPort)],
process.execPath,
[
wranglerCliPath,
"dev",
"--local",
"--ip",
"127.0.0.1",
"--port",
String(relayPort),
"--inspector-ip",
"127.0.0.1",
"--inspector-port",
String(inspectorPort),
"--live-reload=false",
"--show-interactive-dev-session=false",
],
{
cwd: relayDir,
env: { ...process.env },
@@ -570,7 +626,7 @@ async function startRelay(excludedPorts: Set<number>): Promise<number> {
try {
await awaitRelayReady(relayProcess, relayPort, state, buffer);
return relayPort;
return { relayPort, inspectorPort };
} catch (error) {
lastRelayStartupError = error;
await stopProcess(relayProcess);
@@ -587,13 +643,23 @@ async function startRelay(excludedPorts: Set<number>): Promise<number> {
);
}
function startMetro(metroPort: number, buffer: ReturnType<typeof createLineBuffer>): ChildProcess {
function startMetro(input: {
metroPort: number;
daemonPort: number;
buffer: ReturnType<typeof createLineBuffer>;
}): ChildProcess {
const appDir = path.resolve(__dirname, "..");
const child = spawn("npx", ["expo", "start", "--web", "--port", String(metroPort)], {
const child = spawn("npx", ["expo", "start", "--web", "--port", String(input.metroPort)], {
cwd: appDir,
env: {
...process.env,
BROWSER: "none",
...(process.env.E2E_DESKTOP_RUNTIME === "1"
? {
PASEO_WEB_PLATFORM: "electron",
EXPO_PUBLIC_LOCAL_DAEMON: `127.0.0.1:${input.daemonPort}`,
}
: {}),
},
stdio: ["ignore", "pipe", "pipe"],
detached: false,
@@ -605,7 +671,7 @@ function startMetro(metroPort: number, buffer: ReturnType<typeof createLineBuffe
.split("\n")
.filter((line) => line.trim());
for (const line of lines) {
buffer.add(`[stdout] ${line}`);
input.buffer.add(`[stdout] ${line}`);
console.log(`[metro] ${line}`);
}
});
@@ -616,7 +682,7 @@ function startMetro(metroPort: number, buffer: ReturnType<typeof createLineBuffe
.split("\n")
.filter((line) => line.trim());
for (const line of lines) {
buffer.add(`[stderr] ${line}`);
input.buffer.add(`[stderr] ${line}`);
console.error(`[metro] ${line}`);
}
});
@@ -629,41 +695,32 @@ interface DaemonSpawnArgs {
relayPort: number;
metroPort: number;
paseoHome: string;
fakeToolBinDir: string;
fakeEditorBinDir: string;
editorRecordPath: string;
dictation: DictationConfig;
buffer: ReturnType<typeof createLineBuffer>;
}
function startDaemon(args: DaemonSpawnArgs): ChildProcess {
const serverDir = path.resolve(__dirname, "../../..", "packages/server");
const tsxBin = execSync("which tsx").toString().trim();
const { openAiUsable, localModelsDir } = args.dictation;
const env = withDisabledE2ESpeechEnv({
...process.env,
PATH: `${args.fakeEditorBinDir}${path.delimiter}${process.env.PATH ?? ""}`,
PASEO_HOME: args.paseoHome,
PASEO_E2E_EDITOR_RECORD_PATH: args.editorRecordPath,
PASEO_SERVER_ID: "srv_e2e_test_daemon",
PASEO_LISTEN:
process.env.E2E_DESKTOP_RUNTIME === "1" ? `127.0.0.1:${args.port}` : `0.0.0.0:${args.port}`,
PASEO_RELAY_ENDPOINT: `127.0.0.1:${args.relayPort}`,
PASEO_CORS_ORIGINS: `http://localhost:${args.metroPort}`,
PASEO_NODE_ENV: "development",
NODE_ENV: "development",
...(process.env.E2E_DESKTOP_RUNTIME === "1" ? { PASEO_DESKTOP_MANAGED: "1" } : {}),
});
const child = spawn(tsxBin, ["scripts/supervisor-entrypoint.ts", "--dev"], {
cwd: serverDir,
env: {
...process.env,
PATH: `${args.fakeToolBinDir}${path.delimiter}${process.env.PATH ?? ""}`,
PASEO_HOME: args.paseoHome,
PASEO_E2E_EDITOR_RECORD_PATH: args.editorRecordPath,
PASEO_SERVER_ID: "srv_e2e_test_daemon",
PASEO_LISTEN: `0.0.0.0:${args.port}`,
PASEO_RELAY_ENDPOINT: `127.0.0.1:${args.relayPort}`,
PASEO_CORS_ORIGINS: `http://localhost:${args.metroPort}`,
PASEO_DICTATION_ENABLED: openAiUsable ? "1" : "0",
PASEO_VOICE_MODE_ENABLED: openAiUsable ? "1" : "0",
PASEO_NODE_ENV: "development",
...(openAiUsable
? {
PASEO_DICTATION_STT_PROVIDER: "openai",
PASEO_VOICE_STT_PROVIDER: "openai",
PASEO_VOICE_TTS_PROVIDER: "openai",
}
: {}),
...(localModelsDir ? { PASEO_LOCAL_MODELS_DIR: localModelsDir } : {}),
NODE_ENV: "development",
},
env,
stdio: ["ignore", "pipe", "pipe"],
detached: false,
});
@@ -695,24 +752,34 @@ function startDaemon(args: DaemonSpawnArgs): ChildProcess {
return child;
}
async function removeTempTree(targetPath: string): Promise<void> {
await rm(targetPath, {
recursive: true,
force: true,
maxRetries: 40,
retryDelay: 250,
});
}
async function performCleanup(shouldRemovePaseoHome: boolean): Promise<void> {
await Promise.all([
stopProcess(daemonProcess),
stopProcess(metroProcess),
stopProcess(relayProcess),
]);
await stopCurrentDaemonFromPidLock();
daemonProcess = null;
metroProcess = null;
relayProcess = null;
if (paseoHome && shouldRemovePaseoHome) {
await rm(paseoHome, { recursive: true, force: true });
await removeTempTree(paseoHome);
paseoHome = null;
} else if (paseoHome) {
console.log(`[e2e] Preserving PASEO_HOME: ${paseoHome}`);
}
if (fakeToolBinDir) {
await rm(fakeToolBinDir, { recursive: true, force: true });
fakeToolBinDir = null;
if (fakeEditorBinDir) {
await removeTempTree(fakeEditorBinDir);
fakeEditorBinDir = null;
}
}
@@ -721,13 +788,13 @@ export default async function globalSetup() {
ensureRelayBuildArtifact(repoRoot);
await loadEnvTestFile(repoRoot);
const port = await getAvailablePort();
const metroPort = await getAvailablePort();
const port = await getAvailablePortExcluding(new Set());
const metroPort = await getAvailablePortExcluding(new Set([port]));
const requestedPaseoHome = resolveOptionalPaseoHomeEnv(process.env.E2E_PASEO_HOME);
const shouldRemovePaseoHome = !requestedPaseoHome && process.env.E2E_KEEP_PASEO_HOME !== "1";
paseoHome = requestedPaseoHome ?? (await mkdtemp(path.join(tmpdir(), "paseo-e2e-home-")));
const editorRecordPath = path.join(paseoHome, "editor-open-records.jsonl");
fakeToolBinDir = await createFakeToolBin();
fakeEditorBinDir = await createFakeEditorBin();
const metroLineBuffer = createLineBuffer();
const daemonLineBuffer = createLineBuffer();
@@ -735,39 +802,40 @@ export default async function globalSetup() {
const cleanup = () => performCleanup(shouldRemovePaseoHome);
const dictation = await resolveDictationConfig();
await logSpeechHarnessConfig();
try {
const relayPort = await startRelay(new Set([port, metroPort]));
metroProcess = startMetro(metroPort, metroLineBuffer);
metroProcess = startMetro({
metroPort,
daemonPort: port,
buffer: metroLineBuffer,
});
await waitForMetro(metroPort, {
label: "Metro web server",
timeoutMs: 120000,
childProcess: metroProcess,
getRecentOutput: metroLineBuffer.dump,
});
const { relayPort, inspectorPort } = await startRelay(new Set([port, metroPort]));
daemonProcess = startDaemon({
port,
relayPort,
metroPort,
paseoHome,
fakeToolBinDir,
fakeEditorBinDir,
editorRecordPath,
dictation,
buffer: daemonLineBuffer,
});
await Promise.all([
waitForServer(port, {
label: "Paseo daemon",
childProcess: daemonProcess,
getRecentOutput: daemonLineBuffer.dump,
}),
waitForServer(metroPort, {
label: "Metro web server",
timeoutMs: 120000,
childProcess: metroProcess,
getRecentOutput: metroLineBuffer.dump,
}),
]);
await waitForServer(port, {
label: "Paseo daemon",
childProcess: daemonProcess,
getRecentOutput: daemonLineBuffer.dump,
});
const offer = await waitForPairingOfferFromCli({
repoRoot,
paseoHome,
const offer = await waitForPairingOfferFromDaemon({
port,
});
process.env.E2E_DAEMON_PORT = String(port);
@@ -778,7 +846,7 @@ export default async function globalSetup() {
process.env.E2E_PASEO_HOME = paseoHome;
process.env.E2E_EDITOR_RECORD_PATH = editorRecordPath;
console.log(
`[e2e] Test daemon started on port ${port}, Metro on port ${metroPort}, home: ${paseoHome}`,
`[e2e] Test daemon started on port ${port}, Metro on port ${metroPort}, relay on port ${relayPort}, relay inspector on port ${inspectorPort}, home: ${paseoHome}`,
);
return async () => {

View File

@@ -0,0 +1,95 @@
import { expect, type Locator, type Page } from "@playwright/test";
export type AddProjectFlowPage =
| "host"
| "method"
| "directory-search"
| "github-search"
| "github-location"
| "new-directory-parent"
| "new-directory-name";
export type AddProjectMethod = "directory-search" | "browse" | "github" | "new-directory";
const METHOD_DESTINATIONS: Record<Exclude<AddProjectMethod, "browse">, AddProjectFlowPage> = {
"directory-search": "directory-search",
github: "github-search",
"new-directory": "new-directory-parent",
};
export function addProjectFlow(page: Page): Locator {
return page.getByTestId("add-project-flow");
}
export function addProjectFlowInput(page: Page): Locator {
return page.getByTestId("add-project-flow-input");
}
export function addProjectFlowBack(page: Page): Locator {
return page.getByTestId("add-project-flow-back");
}
export function addProjectFlowHost(page: Page, serverId: string): Locator {
return page.getByTestId(`add-project-flow-host-${serverId}`);
}
export function addProjectFlowMethod(page: Page, method: AddProjectMethod): Locator {
return page.getByTestId(`add-project-flow-method-${method}`);
}
export async function waitForConnectedHost(
page: Page,
input: { serverId: string; endpoint: string },
): Promise<void> {
await page.getByTestId("sidebar-hosts-trigger").click();
const host = page.getByTestId(`sidebar-host-row-${input.serverId}`);
await expect(host).toContainText(input.endpoint, { timeout: 30_000 });
await page.keyboard.press("Escape");
await expect(host).not.toBeVisible();
}
export async function expectAddProjectPage(page: Page, kind: AddProjectFlowPage): Promise<Locator> {
const currentPage = page.getByTestId(`add-project-flow-page-${kind}`);
await expect(currentPage).toBeVisible({ timeout: 30_000 });
return currentPage;
}
export async function openAddProjectFlow(
page: Page,
expectedPage: "host" | "method" = "method",
): Promise<void> {
await page.getByTestId("sidebar-add-project").click();
await expect(addProjectFlow(page)).toBeVisible({ timeout: 30_000 });
await expectAddProjectPage(page, expectedPage);
await expect(addProjectFlowInput(page)).toBeFocused();
}
export async function chooseAddProjectMethod(page: Page, method: AddProjectMethod): Promise<void> {
const option = addProjectFlowMethod(page, method);
await expect(option).toBeVisible();
await option.click();
if (method !== "browse") {
await expectAddProjectPage(page, METHOD_DESTINATIONS[method]);
}
}
export async function expectNewWorkspaceForAddedProject(
page: Page,
input: {
serverId: string;
projectId: string;
projectName: string;
projectPath: string;
},
): Promise<void> {
await expect(page).toHaveURL(/\/new\?.*projectId=/u, { timeout: 30_000 });
const url = new URL(page.url());
expect(url.pathname).toBe("/new");
expect(url.searchParams.get("serverId")).toBe(input.serverId);
expect(url.searchParams.get("projectId")).toBe(input.projectId);
expect(url.searchParams.get("dir")).toBe(input.projectPath);
await expect(page.getByRole("button", { name: "Workspace project" })).toContainText(
input.projectName,
{ timeout: 30_000 },
);
}

View File

@@ -1,6 +1,7 @@
import { expect, type Page } from "@playwright/test";
const NEAR_BOTTOM_THRESHOLD_PX = 72;
const DEFAULT_SCROLL_TOLERANCE_PX = 24;
export interface ScrollMetrics {
offsetY: number;
@@ -54,6 +55,25 @@ export async function expectNearBottom(page: Page): Promise<void> {
.toBeLessThanOrEqual(NEAR_BOTTOM_THRESHOLD_PX);
}
export async function scrollAgentChatToBottom(page: Page): Promise<void> {
const chatScroll = getVisibleChatScroll(page);
await chatScroll.evaluate((root: Element) => {
const scrollElement = root as HTMLElement;
scrollElement.scrollTop = scrollElement.scrollHeight;
});
await expect
.poll(async () =>
chatScroll.evaluate((root: Element) => {
const scrollElement = root as HTMLElement;
return Math.max(
0,
scrollElement.scrollHeight - (scrollElement.scrollTop + scrollElement.clientHeight),
);
}),
)
.toBeLessThanOrEqual(NEAR_BOTTOM_THRESHOLD_PX);
}
export async function waitForContentGrowth(
page: Page,
previousContentHeight: number,
@@ -66,3 +86,170 @@ export async function waitForContentGrowth(
.toBeGreaterThan(previousContentHeight);
return readScrollMetrics(page);
}
export async function waitForScrollableChat(
page: Page,
input: { minScrollableDistance: number; timeout?: number },
): Promise<void> {
await expect
.poll(
async () => {
const metrics = await readScrollMetrics(page);
return metrics.contentHeight - metrics.viewportHeight;
},
{ timeout: input.timeout },
)
.toBeGreaterThan(input.minScrollableDistance);
}
export async function scrollChatAwayFromBottom(
page: Page,
input: { deltaY: number; minDistanceFromBottom: number },
): Promise<ScrollMetrics> {
const scroll = getVisibleChatScroll(page);
const box = await scroll.boundingBox();
if (!box) {
throw new Error("Agent chat scroll container is not visible");
}
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.wheel(0, input.deltaY);
await expect
.poll(async () => {
const metrics = await readScrollMetrics(page);
return metrics.distanceFromBottom;
})
.toBeGreaterThan(input.minDistanceFromBottom);
return readScrollMetrics(page);
}
export async function clickToolCallBesideScrollToBottomButton(page: Page): Promise<{
outsideButton: boolean;
toolCallReceivesPointer: boolean;
withinButtonBand: boolean;
}> {
await scrollChatAwayFromBottom(page, {
deltaY: -900,
minDistanceFromBottom: 300,
});
const scrollToBottomButton = page.getByRole("button", { name: "Scroll to bottom" });
await expect(scrollToBottomButton).toBeVisible();
const buttonBounds = await scrollToBottomButton.boundingBox();
expect(buttonBounds, "Expected visible scroll-to-bottom button bounds").not.toBeNull();
const visibleButtonBounds = buttonBounds!;
const toolCalls = page.locator('[data-testid="tool-call-badge"] [role="button"]');
const toolCallBounds = await Promise.all(
Array.from({ length: await toolCalls.count() }, async (_, index) => ({
index,
bounds: await toolCalls.nth(index).boundingBox(),
})),
);
const buttonCenterY = visibleButtonBounds.y + visibleButtonBounds.height / 2;
const candidate = toolCallBounds
.filter(
(entry): entry is { index: number; bounds: NonNullable<typeof entry.bounds> } =>
entry.bounds !== null && entry.bounds.width > 0,
)
.sort(
(left, right) =>
Math.abs(left.bounds.y + left.bounds.height / 2 - buttonCenterY) -
Math.abs(right.bounds.y + right.bounds.height / 2 - buttonCenterY),
)[0];
expect(
candidate,
`Expected at least one rendered tool-call badge: ${JSON.stringify({
buttonBounds,
scrollMetrics: await readScrollMetrics(page),
toolCallBounds,
})}`,
).toBeDefined();
const visibleToolCall = candidate!;
const initialToolCallCenterY = visibleToolCall.bounds.y + visibleToolCall.bounds.height / 2;
await getVisibleChatScroll(page).evaluate((scroll, deltaY) => {
(scroll as HTMLElement).scrollTop += deltaY;
}, initialToolCallCenterY - buttonCenterY);
const alignedToolCall = toolCalls.nth(visibleToolCall.index);
await expect
.poll(async () => {
const [currentButtonBounds, currentToolCallBounds] = await Promise.all([
scrollToBottomButton.boundingBox(),
alignedToolCall.boundingBox(),
]);
if (!currentButtonBounds || !currentToolCallBounds) {
return false;
}
const toolCallCenterY = currentToolCallBounds.y + currentToolCallBounds.height / 2;
return (
toolCallCenterY >= currentButtonBounds.y &&
toolCallCenterY <= currentButtonBounds.y + currentButtonBounds.height
);
})
.toBe(true);
const [alignedButtonBounds, visibleToolCallBounds] = await Promise.all([
scrollToBottomButton.boundingBox(),
alignedToolCall.boundingBox(),
]);
expect(alignedButtonBounds, "Expected scroll-to-bottom button to remain visible").not.toBeNull();
expect(
visibleToolCallBounds,
"Expected aligned tool-call badge to remain visible",
).not.toBeNull();
const finalButtonBounds = alignedButtonBounds!;
const finalToolCallBounds = visibleToolCallBounds!;
const clickPoint = {
x: finalToolCallBounds.x + 24,
y: finalToolCallBounds.y + finalToolCallBounds.height / 2,
};
const toolCallReceivesPointer = await alignedToolCall.evaluate((toolCall, point) => {
const hit = document.elementFromPoint(point.x, point.y);
return hit !== null && toolCall.contains(hit);
}, clickPoint);
const hitArea = {
clickPoint,
outsideButton:
clickPoint.x < finalButtonBounds.x ||
clickPoint.x > finalButtonBounds.x + finalButtonBounds.width,
toolCallReceivesPointer,
withinButtonBand:
clickPoint.y >= finalButtonBounds.y &&
clickPoint.y <= finalButtonBounds.y + finalButtonBounds.height,
};
await page.mouse.click(hitArea.clickPoint.x, hitArea.clickPoint.y);
return {
outsideButton: hitArea.outsideButton,
toolCallReceivesPointer: hitArea.toolCallReceivesPointer,
withinButtonBand: hitArea.withinButtonBand,
};
}
export async function expectScrollStaysFixed(
page: Page,
baseline: ScrollMetrics,
input?: { durationMs?: number; sampleIntervalMs?: number; tolerancePx?: number },
): Promise<void> {
const durationMs = input?.durationMs ?? 2_000;
const sampleIntervalMs = input?.sampleIntervalMs ?? 250;
const tolerancePx = input?.tolerancePx ?? DEFAULT_SCROLL_TOLERANCE_PX;
const samples: Array<{ elapsedMs: number; offsetY: number; contentHeight: number }> = [];
const startedAt = Date.now();
while (Date.now() - startedAt < durationMs) {
await page.waitForTimeout(sampleIntervalMs);
const metrics = await readScrollMetrics(page);
samples.push({
elapsedMs: Date.now() - startedAt,
offsetY: metrics.offsetY,
contentHeight: metrics.contentHeight,
});
expect(
metrics.offsetY,
JSON.stringify({ baseline, samples: samples.slice(-12) }),
).toBeLessThanOrEqual(baseline.offsetY + tolerancePx);
}
}

View File

@@ -0,0 +1,120 @@
import type { Page } from "@playwright/test";
import { daemonWsRoutePattern } from "./daemon-port";
type WebSocketMessage = string | Buffer;
interface CreatedAgentTimelineGate {
release(): void;
waitForCreatedAgent(): Promise<string>;
waitForDelayedResponse(): Promise<void>;
waitForForwardedResponse(): Promise<void>;
}
function parseWebSocketJson(message: WebSocketMessage): unknown {
const rawMessage = typeof message === "string" ? message : message.toString("utf8");
try {
return JSON.parse(rawMessage);
} catch {
return null;
}
}
function getSessionMessage(message: WebSocketMessage): Record<string, unknown> | null {
const envelope = parseWebSocketJson(message);
if (!envelope || typeof envelope !== "object") {
return null;
}
const maybeEnvelope = envelope as { type?: unknown; message?: unknown };
if (maybeEnvelope.type !== "session" || !maybeEnvelope.message) {
return null;
}
if (typeof maybeEnvelope.message !== "object") {
return null;
}
return maybeEnvelope.message as Record<string, unknown>;
}
function getPayload(message: Record<string, unknown>): Record<string, unknown> | null {
return message.payload && typeof message.payload === "object"
? (message.payload as Record<string, unknown>)
: null;
}
export async function delayCreatedAgentInitialTailResponse(
page: Page,
): Promise<CreatedAgentTimelineGate> {
let createdAgentId: string | null = null;
let releaseRequested = false;
let delayedResponseSeen = false;
const delayedForwards: Array<() => void> = [];
let resolveCreatedAgent: ((agentId: string) => void) | null = null;
let resolveDelayedResponse: (() => void) | null = null;
let resolveForwardedResponse: (() => void) | null = null;
const createdAgentSeen = new Promise<string>((resolve) => {
resolveCreatedAgent = resolve;
});
const delayedResponse = new Promise<void>((resolve) => {
resolveDelayedResponse = resolve;
});
const forwardedResponse = new Promise<void>((resolve) => {
resolveForwardedResponse = resolve;
});
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const server = ws.connectToServer();
const forwardToClient = (message: WebSocketMessage) => {
ws.send(message);
resolveForwardedResponse?.();
};
ws.onMessage((message) => {
server.send(message);
});
server.onMessage((message) => {
const sessionMessage = getSessionMessage(message);
const payload = sessionMessage ? getPayload(sessionMessage) : null;
if (sessionMessage?.type === "status" && payload?.status === "agent_created") {
const agentId = payload.agentId;
if (typeof agentId === "string") {
createdAgentId = agentId;
resolveCreatedAgent?.(agentId);
}
}
if (sessionMessage?.type === "fetch_agent_timeline_response") {
const agentId = payload?.agentId;
const direction = payload?.direction;
if (
!delayedResponseSeen &&
typeof agentId === "string" &&
agentId === createdAgentId &&
direction === "tail"
) {
delayedResponseSeen = true;
resolveDelayedResponse?.();
if (releaseRequested) {
forwardToClient(message);
return;
}
delayedForwards.push(() => forwardToClient(message));
return;
}
}
ws.send(message);
});
});
return {
release() {
releaseRequested = true;
for (const forward of delayedForwards.splice(0)) {
forward();
}
},
waitForCreatedAgent: () => createdAgentSeen,
waitForDelayedResponse: () => delayedResponse,
waitForForwardedResponse: () => forwardedResponse,
};
}

View File

@@ -342,9 +342,17 @@ export const selectModel = async (page: Page, model: string) => {
if (await modelTrigger.isVisible().catch(() => false)) {
await modelTrigger.click();
} else {
const modelLabel = page.getByText("MODEL", { exact: true }).first();
await expect(modelLabel).toBeVisible();
await modelLabel.click();
const modelButton = page
.getByRole("button", { name: /Select model/i })
.filter({ visible: true })
.first();
if (await modelButton.isVisible().catch(() => false)) {
await modelButton.click();
} else {
const modelLabel = page.getByText("MODEL", { exact: true }).first();
await expect(modelLabel).toBeVisible();
await modelLabel.click();
}
}
// Wait for the model dropdown to open

View File

@@ -3,17 +3,19 @@ import { expect, type Page } from "@playwright/test";
import { buildCreateAgentPreferences, buildSeededHost } from "./daemon-registry";
import { getE2EDaemonPort } from "./daemon-port";
import { getServerId } from "./server-id";
import { expectAppRoute } from "./route-assertions";
import { waitForWorkspaceTabsVisible } from "./workspace-tabs";
import {
buildHostAgentDetailRoute,
buildHostSessionsRoute,
buildHostWorkspaceRoute,
buildSessionsRoute,
} from "@/utils/host-routes";
export interface ArchiveTabAgent {
id: string;
title: string;
cwd: string;
workspaceId: string;
}
function buildSeededStoragePayload() {
@@ -38,7 +40,9 @@ export interface IdleAgentSeedClient {
provider: string;
model: string;
modeId: string;
featureValues?: Record<string, unknown>;
cwd: string;
workspaceId: string;
title: string;
}): Promise<{ id: string }>;
waitForAgentUpsert(
@@ -50,13 +54,18 @@ export interface IdleAgentSeedClient {
export async function createIdleAgent(
client: IdleAgentSeedClient,
input: { cwd: string; title: string },
input: { cwd: string; workspaceId: string; title: string },
): Promise<ArchiveTabAgent> {
const created = await client.createAgent({
provider: "opencode",
model: "opencode/gpt-5-nano",
modeId: "bypassPermissions",
// OpenCode has no "bypassPermissions" mode (that's Claude's). Use build with
// auto_accept for unattended full access — mode validation now rejects modes
// the provider doesn't define.
modeId: "build",
featureValues: { auto_accept: true },
cwd: input.cwd,
workspaceId: input.workspaceId,
title: input.title,
});
const snapshot = await client.waitForAgentUpsert(
@@ -71,6 +80,7 @@ export async function createIdleAgent(
id: created.id,
title: input.title,
cwd: input.cwd,
workspaceId: input.workspaceId,
};
}
@@ -81,6 +91,18 @@ export async function archiveAgentFromDaemon(
await client.archiveAgent(agentId);
}
export async function fetchAgentArchivedAt(
client: {
fetchAgent(options: {
agentId: string;
}): Promise<{ agent: { archivedAt?: string | null } } | null>;
},
agentId: string,
): Promise<string | null> {
const result = await client.fetchAgent({ agentId });
return result?.agent.archivedAt ?? null;
}
export async function primeAdditionalPage(page: Page): Promise<void> {
const seedNonce = randomUUID();
const { daemon, preferences } = buildSeededStoragePayload();
@@ -133,7 +155,7 @@ export async function openWorkspaceWithAgents(
): Promise<void> {
const serverId = getServerId();
for (const agent of agents) {
await page.goto(buildHostAgentDetailRoute(serverId, agent.id, agent.cwd));
await page.goto(buildHostAgentDetailRoute(serverId, agent.id, agent.workspaceId));
// The workspace layout consumes `?open=agent:xxx`, returns null during the effect,
// then replaces the URL with the clean workspace route after preparing the tab.
@@ -199,10 +221,8 @@ export async function openSessions(page: Page): Promise<void> {
const sessionsButton = page.getByTestId("sidebar-sessions");
await expect(sessionsButton).toBeVisible({ timeout: 30_000 });
await sessionsButton.click();
await expect(page).toHaveURL(new RegExp(`${buildHostSessionsRoute(getServerId())}$`), {
timeout: 30_000,
});
await expect(page.getByText("Sessions", { exact: true }).last()).toBeVisible({
await expectAppRoute(page, buildSessionsRoute(), { timeout: 30_000 });
await expect(page.getByText("History", { exact: true }).last()).toBeVisible({
timeout: 30_000,
});
}
@@ -221,6 +241,12 @@ export async function expectSessionRowArchived(page: Page, title: string): Promi
await expect(getSessionRowByTitle(page, title)).toContainText("Archived", { timeout: 30_000 });
}
export async function expectSessionRowNotArchived(page: Page, title: string): Promise<void> {
await expect(getSessionRowByTitle(page, title)).not.toContainText("Archived", {
timeout: 30_000,
});
}
export async function clickSessionRow(page: Page, title: string): Promise<void> {
const row = getSessionRowByTitle(page, title);
await expect(row).toBeVisible({ timeout: 30_000 });

View File

@@ -0,0 +1,67 @@
import { expect, type Page } from "@playwright/test";
import { escapeRegex } from "./regex";
// The branch switcher lives in the git diff panel's Changes header (right-side
// ExplorerSidebar), not in the workspace header. It renders as a button whose
// accessible name carries the current branch ("Current branch: <name>. Press to
// switch branch."). Matching on the accessible name keeps these helpers tied to
// what a screen reader user hears, and it proves the panel resolved a real
// checkout directory from the opaque workspace id. Scoping to the changes header
// keeps the matcher unambiguous even when the header is shared with diff actions.
function branchSwitcherTrigger(page: Page, branchName: string) {
return page
.getByTestId("changes-header")
.getByRole("button", { name: new RegExp(`Current branch: ${escapeRegex(branchName)}\\b`) })
.filter({ visible: true })
.first();
}
// Opens the right-side explorer and lands on the Changes tab, where the branch
// switcher and diff live. Git checkouts default to the Changes tab when the
// explorer opens, so this is enough to reveal the switcher on desktop and mobile.
export async function openChangesPanel(page: Page): Promise<void> {
await expect(page.getByTestId("workspace-explorer-toggle").first()).toBeVisible({
timeout: 30_000,
});
await page.getByTestId("workspace-explorer-toggle").first().click();
const changesTab = page.getByTestId("explorer-tab-changes").filter({ visible: true }).first();
await expect(changesTab).toBeVisible({ timeout: 30_000 });
await changesTab.click();
await expect(page.getByTestId("changes-header").filter({ visible: true }).first()).toBeVisible({
timeout: 30_000,
});
}
export async function expectWorkspaceBranch(page: Page, branchName: string): Promise<void> {
await expect(branchSwitcherTrigger(page, branchName)).toBeVisible({ timeout: 30_000 });
}
export async function switchBranchFromChangesPanel(
page: Page,
input: { from: string; to: string },
): Promise<void> {
await branchSwitcherTrigger(page, input.from).click();
const picker = page.getByTestId("combobox-desktop-container");
await expect(picker).toBeVisible({ timeout: 30_000 });
// The branch switcher combobox renders its options as plain text rows with no ARIA
// role, so filter by the visible branch name and click the matching row. Filtering
// first guarantees a single, unambiguous match.
const search = page.getByPlaceholder("Filter branches...");
await expect(search).toBeVisible({ timeout: 30_000 });
await search.fill(input.to);
const option = picker.getByText(input.to, { exact: true });
await expect(option).toBeVisible({ timeout: 30_000 });
await option.click();
await expect(picker).not.toBeVisible({ timeout: 30_000 });
}
// The workspace header title is a plain static title in Model B; the branch
// switcher must never appear there. Asserting on the header testID keeps this
// honest even as the switcher continues to exist inside the Changes panel.
export async function expectNoBranchSwitcherInWorkspaceHeader(page: Page): Promise<void> {
await expect(page.getByTestId("workspace-header-branch-switcher")).toHaveCount(0);
}

View File

@@ -0,0 +1,9 @@
import { expect, type Locator, type Page } from "@playwright/test";
// Opens the command center / global search palette from the sidebar and returns its panel.
export async function openCommandCenter(page: Page): Promise<Locator> {
await page.getByTestId("sidebar-command-center-search").click();
const panel = page.getByTestId("command-center-panel");
await expect(panel).toBeVisible({ timeout: 30_000 });
return panel;
}

View File

@@ -2,7 +2,8 @@ import { expect, type Page } from "@playwright/test";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import { createTempGitRepo } from "./workspace";
import { connectSeedClient, type SeedDaemonClient } from "./seed-client";
import { connectWorkspaceSetupClient, openHomeWithProject } from "./workspace-setup";
import { gotoAppShell } from "./app";
import { connectWorkspaceSetupClient } from "./workspace-setup";
import { selectWorkspaceInSidebar } from "./sidebar";
import { getServerId } from "./server-id";
import { waitForTabBar } from "./launcher";
@@ -94,6 +95,33 @@ export async function expectAttachmentPill(page: Page, testID: string): Promise<
await expect(page.getByTestId(testID).first()).toBeVisible({ timeout: 10_000 });
}
export async function dropFileOnComposer(
page: Page,
file: { name: string; mimeType: string; buffer: Buffer },
): Promise<void> {
const dataTransfer = await page.evaluateHandle(
({ name, mimeType, base64 }) => {
const bytes = Uint8Array.from(atob(base64), (char) => char.charCodeAt(0));
const droppedFile = new File([bytes], name, { type: mimeType });
const transfer = new DataTransfer();
transfer.items.add(droppedFile);
return transfer;
},
{
name: file.name,
mimeType: file.mimeType,
base64: file.buffer.toString("base64"),
},
);
const composerRoot = page.getByTestId("message-input-root").filter({ visible: true }).first();
await expect(composerRoot).toBeVisible({ timeout: 10_000 });
await composerRoot.dispatchEvent("dragenter", { dataTransfer });
await composerRoot.dispatchEvent("dragover", { dataTransfer });
await composerRoot.dispatchEvent("drop", { dataTransfer });
await dataTransfer.dispose();
}
/** Hover to reveal the X button (hidden until hover on desktop web), then click by accessible label. */
export async function removeAttachmentPill(
page: Page,
@@ -148,6 +176,7 @@ export async function selectGithubOption(
export interface MockAgentSetup {
client: SeedDaemonClient;
repo: Awaited<ReturnType<typeof createTempGitRepo>>;
cleanup: () => Promise<void>;
}
/** Create a temp repo, start a mock agent, navigate to it, and wait for it to be running. */
@@ -159,21 +188,35 @@ export async function startRunningMockAgent(
const repo = await createTempGitRepo(opts.prefix);
const client = await connectSeedClient();
const opened = await client.openProject(repo.path);
if (!opened.workspace) throw new Error(opened.error ?? "Failed to open project");
const createdWorkspace = await client.createWorkspace({
source: { kind: "directory", path: repo.path },
});
if (!createdWorkspace.workspace) {
throw new Error(createdWorkspace.error ?? "Failed to create workspace");
}
const workspace = createdWorkspace.workspace;
const agent = await client.createAgent({
provider: "mock",
cwd: repo.path,
workspaceId: workspace.id,
model: opts.model,
initialPrompt: opts.prompt,
});
const agentUrl = `${buildHostWorkspaceRoute(serverId, repo.path)}?open=${encodeURIComponent(`agent:${agent.id}`)}`;
const agentUrl = `${buildHostWorkspaceRoute(serverId, workspace.id)}?open=${encodeURIComponent(`agent:${agent.id}`)}`;
await page.goto(agentUrl);
await expectComposerVisible(page);
await client.sendAgentMessage(agent.id, opts.prompt);
await expect(page.getByRole("button", { name: /stop|cancel/i }).first()).toBeVisible({
timeout: 30_000,
});
await expectComposerVisible(page);
return { client, repo };
return {
client,
repo,
cleanup: async () => {
await client.removeProject(workspace.projectId).catch(() => undefined);
await client.close().catch(() => undefined);
await repo.cleanup().catch(() => undefined);
},
};
}
export interface GithubWorkspaceHandle {
@@ -186,10 +229,20 @@ export async function openGithubWorkspace(
repoPath: string,
): Promise<GithubWorkspaceHandle> {
const client = await connectWorkspaceSetupClient();
const opened = await client.openProject(repoPath);
if (!opened.workspace) throw new Error(opened.error ?? `Failed to open project ${repoPath}`);
await openHomeWithProject(page, repoPath);
await selectWorkspaceInSidebar(page, opened.workspace.id);
const createdWorkspace = await client.createWorkspace({
source: { kind: "directory", path: repoPath },
});
if (!createdWorkspace.workspace) {
throw new Error(createdWorkspace.error ?? `Failed to create workspace ${repoPath}`);
}
const workspace = createdWorkspace.workspace;
await gotoAppShell(page);
await selectWorkspaceInSidebar(page, workspace.id);
await waitForTabBar(page);
return { cleanup: () => client.close().catch(() => undefined) };
return {
cleanup: async () => {
await client.removeProject(workspace.projectId).catch(() => undefined);
await client.close().catch(() => undefined);
},
};
}

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