Compare commits

...

1135 Commits

Author SHA1 Message Date
Mohamed Boudra
98c0033f22 test(daemon): make self-update paths platform-stable 2026-06-26 13:25:17 +07:00
Mohamed Boudra
444214cbe2 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.
2026-06-26 13:02:35 +07:00
Davy
7f6403f517 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
2026-06-26 12:54:17 +07:00
Davy
be8c2a54ce 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 }
2026-06-26 12:53:23 +07:00
Davy
c6c030ab26 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.
2026-06-26 12:53:23 +07: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
Mohamed Boudra
41cb1af036 Add handle pan-down fallback to bottom-sheet reopen e2e
The "model selector can open, close, reopen, and close again" test still flaked
on loaded CI runners even after re-tapping the backdrop: the model-selector
sheet re-renders as its model list settles, and Gorhom drops backdrop presses
during that churn, so the tap never dismisses within the retry window.

Fall back to dragging the sheet handle down, which drives Gorhom's pan-to-close
directly and is unaffected by the backdrop churn (mirrors the resilient close in
e2e/helpers/app.ts). The backdrop tap remains the primary path, and the
post-close "stays hidden" guard that protects the dismiss-then-re-present
regression is unchanged. Verified locally by forcing the fallback (backdrop tap
disabled): the handle drag closes the sheet on its own.
2026-05-31 08:38:46 +07:00
Mohamed Boudra
17aa957d3e Stabilize bottom-sheet reopen e2e by retrying the backdrop tap (#1238)
The "model selector can open, close, reopen, and close again" test fails on
loaded CI runners: a single backdrop tap that lands while the sheet is still
present-animating is dropped (Gorhom ignores backdrop presses until the sheet
settles at its snap point), so the sheet never dismisses and the backdrop stays
visible. The reopen behaviour itself is correct — the visibility tracker is
unit-tested and the spec passes locally (3x), so this is test-interaction
fragility, not a product regression.

Re-tap the backdrop until the sheet dismisses, mirroring the resilient close
loop already used in e2e/helpers/app.ts. It stays a pure backdrop dismissal (no
Escape/pan fallback) so the real close path is still exercised, and the
post-close "stays hidden" guard that protects the dismiss-then-re-present
regression is unchanged.
2026-05-30 22:59:02 +08:00
paseo-ai[bot]
2c756e9a8a fix: update lockfile signatures and Nix hash [skip ci] 2026-05-30 02:53:33 +00:00
Mohamed Boudra
bb6a4db6e0 chore(release): cut 0.1.87 2026-05-30 09:49:45 +07:00
Mohamed Boudra
2a13a082b7 Changelog for 0.1.87 2026-05-30 09:48:43 +07:00
Mohamed Boudra
eea5932a21 Fix mobile bottom sheets reopening after dismiss 2026-05-30 09:43:49 +07:00
paseo-ai[bot]
e04bb942e2 fix: update lockfile signatures and Nix hash [skip ci] 2026-05-29 16:05:39 +00:00
Mohamed Boudra
3c3574d670 Fix intermittent Android crash by upgrading Reanimated to 4.3.1
Reanimated 4.1.x crashes on Android with a NullPointerException in
ViewGroup.dispatchDraw (ReactViewGroup.dispatchDraw) — a child view is
null mid-draw because Reanimated commits a ShadowTree change during the
Android draw pass, mutating the view hierarchy while it's being painted.
Fixed upstream in 4.3.0 (software-mansion/react-native-reanimated#9072).

4.3.x requires react-native-worklets 0.8.x, so worklets moves 0.5.1 ->
0.8.3 in lockstep. react-native is pinned to exact 0.81.5 so the
re-resolve can't pull 0.81.6 (which needs react ^19.1.4, conflicting with
the react 19.1.0 override), and reanimated/worklets are added to root
overrides to force a single version across the tree — two copies of the
worklets runtime throw a version-mismatch at startup.
2026-05-29 23:01:07 +07:00
Mohamed Boudra
44863ec1dd Forward subagent permission requests 2026-05-29 22:02:18 +07:00
Mohamed Boudra
47414abc5e website tweaks 2026-05-29 19:33:57 +07:00
paseo-ai[bot]
9860dd36ef fix: update lockfile signatures and Nix hash [skip ci] 2026-05-29 11:50:20 +00:00
Mohamed Boudra
9e0c9d0662 chore(release): cut 0.1.86 2026-05-29 18:46:22 +07:00
Mohamed Boudra
654ebadaf5 Add 0.1.86 changelog entry 2026-05-29 18:27:57 +07:00
Mohamed Boudra
c2b4f3aa18 Add projectIconToDataUri to the projects-screen test mock 2026-05-29 18:27:48 +07:00
Mohamed Boudra
64e3a7152d Publish Electron migration post 2026-05-29 18:27:35 +07:00
Mohamed Boudra
daae71d1a8 Add Grok to the website and supported-providers docs
Adds the /grok SEO landing page (and its auto-generated route/sitemap
entries), the /agents directory card, and the Grok line in the ACP
catalog docs, following the Grok Build provider added to the in-app
catalog.
2026-05-29 18:10:51 +07:00
Mohamed Boudra
c1453b25a7 Add Claude fast mode toggle 2026-05-29 18:01:56 +07:00
Mohamed Boudra
c47f3190d8 Add Grok Build to the ACP provider catalog
Verified the ACP entrypoint against a real install (grok 0.2.11): the
command is `grok agent stdio`, and an initialize handshake returns a
valid ACP response (protocolVersion 1, model grok-build). Running
prompts requires a SuperGrok or X Premium+ login.
2026-05-29 17:59:03 +07:00
Mohamed Boudra
0803d5ff41 Show model selector provider errors 2026-05-29 17:16:35 +07:00
Mohamed Boudra
7ed0ce97df Fix local speech worker audio transport
Worker IPC now carries plain byte payloads instead of Node Buffer objects, so provider code receives process-local Buffers instead of transport-shaped views.
2026-05-29 17:09:01 +07:00
Mohamed Boudra
fcd93a23ac Add metadata generation page to public docs 2026-05-29 15:57:58 +07:00
Mohamed Boudra
ef3b8a42fd Merge branch 'main' of github.com:getpaseo/paseo 2026-05-29 14:16:19 +07:00
Mohamed Boudra
8b1bb90ce3 Show colored icons for projects without an icon
The letter-on-grey placeholder is replaced by a fill whose color is a
deterministic hash of the project key, so each project keeps a stable
color across the sidebar, projects list, and settings header. Mid-
saturation tones keep the white letter legible in both themes.
2026-05-29 14:06:47 +07:00
Mohamed Boudra
524aef5d75 Add Parakeet v3 local STT model for multilingual dictation
v2 is English-only; v3 covers 25 European languages with automatic
language detection. The archive shares v2's filenames, architecture,
sample rate, and feature dim, so no runtime loader changes are needed.

Document that the `language` config field and PASEO_*_LANGUAGE vars
only steer the OpenAI STT provider — the local Parakeet models ignore
them, so multilingual local dictation means selecting the v3 model.
2026-05-29 14:06:18 +07:00
Mohamed Boudra
69d36c76fe Show connected host daemon versions on the About page (#1220)
* Show connected host daemon versions on the About page

* Update About E2E assertion for the App version label
2026-05-29 14:51:53 +08:00
Mohamed Boudra
38f0560f9b Syntax-highlight Edit, Write, and Read tool calls (#1218)
Highlighting reuses the existing code-block tokenizer and LRU cache, runs
lazily on expand, and falls back to plain text above a size cap so a large
Read can't stall the main thread. The diff highlighter reconstructs the old
and new file text by position rather than from @@ line ranges, so diffs that
arrive without real hunk headers still highlight.

Read content is normalized server-side: the cat -n line-number gutter some
providers emit is stripped so content is uniformly raw source, with the first
line number surfaced as offset for the client to rebuild the gutter.
2026-05-29 14:51:14 +08:00
Matt Cowger
25d6fc8515 Prefer configured provider fallbacks for metadata generation (#1219)
* Prefer configured provider fallbacks for metadata generation

* Update client config test for metadata defaults
2026-05-29 06:38:22 +00:00
Mohamed Boudra
d1eb976653 Fix duplicate /clear in the slash command menu
A client command and a provider command can share a name (e.g. clear).
Both produced the same option id, which became a duplicate React key —
React then left stale rows and scrambled ordering, so /clear showed
twice and ranking broke as the query narrowed. Drop the colliding
provider command and keep the client one, which has extra affordances.
2026-05-29 13:23:29 +07:00
Mohamed Boudra
14d176a4e2 Add manual refresh button to git diff controls (#1216)
* feat(git): add manual refresh button to diff controls

Adds a checkout_refresh RPC that forces a hard re-read of the git and
GitHub snapshot plus the diff, bypassing polling. The diff controls now
show a refresh button (feature-gated) that triggers it and surfaces
errors via a toast — an escape hatch when the polled state goes stale.

* refactor(git): address review — dotted RPC name + withUnistyles

- Rename checkout_refresh RPC to the dotted convention
  (checkout.refresh.request/response) per docs/rpc-namespacing.md.
- Replace the banned useUnistyles() call in DiffRefreshButton with
  withUnistyles wrappers for the icon/spinner color, per docs/unistyles.md.

* refactor(git): move refresh into the checkout actions store

Per the feature audit: the manual refresh was the one checkout action
whose UI logic lived inline in the component instead of alongside its
siblings (commit/pull/push) in useCheckoutGitActionsStore. Move it into
the store so there's one home for "UI triggers a checkout RPC" — it now
reuses runCheckoutAction's pending/success status and query invalidation.
The component just reads status and shows an error toast.

* fix(git): size refresh loader to the icon to stop layout shift

ActivityIndicator size="small" renders ~20px regardless of platform,
wider than the 14px refresh icon, so swapping to it grew the button and
shifted the controls row. Use SyncedLoader at the same iconSize — it
renders into a size×size box, so both states share one footprint.

* fix(git): match file-explorer refresh control (RotateCw + LoadingSpinner)

Mirror the file-explorer pane's refresh button exactly: RotateCw icon,
LoadingSpinner while refreshing, both centered in a fixed icon-sized box
so the spinner can't grow the control or shift the row. Replaces the
ad-hoc RefreshCw + SyncedLoader pairing.
2026-05-29 14:23:16 +08:00
Mohamed Boudra
04fb0f9b82 Keep local voice memory out of the daemon (#1217)
* Move local speech work out of daemon

Run local speech models in a lazily spawned worker so native model memory and blocking synthesis/transcription work stay out of the daemon process. The worker exits after idle time so local speech RSS can be reclaimed.

* Update supervisor stop test for process title
2026-05-29 13:27:28 +08:00
Mohamed Boudra
cdcf3d8d55 Merge branch 'main' of github.com:getpaseo/paseo 2026-05-29 12:20:59 +07:00
Mohamed Boudra
cfc9665f6a Fix wrong local speech STT model ID in voice docs
The documented parakeet-tdt-0.6b-v3-int8 doesn't exist in the model
catalog; only the v2 variant is accepted. Copying the doc value into
config produced an "invalid model id" error on daemon restart.
2026-05-29 12:20:22 +07:00
Mohamed Boudra
1350167dda Push the whole composer up with the keyboard on mobile
The keyboard shift was applied only to the input box inside the
composer, so the subagents track and the draft import-session pill —
rendered as siblings above the composer — stayed behind the keyboard.
Lift the shift to the wrapper that holds the full composer column and
add an opt-out prop so the composer skips its own shift when a parent
owns it.
2026-05-29 12:20:00 +07:00
Mohamed Boudra
bb2f540f29 Widen the mobile composer relative to the message column 2026-05-29 12:00:50 +07:00
Mohamed Boudra
f861aa2e88 Tighten mobile composer footer glyph alignment 2026-05-29 11:28:14 +07:00
Mohamed Boudra
3585c80266 Allow previews to open readable files (#1214)
* Allow previews to open readable files

* Fix Windows file preview path test

* Address file preview review comments
2026-05-29 11:35:38 +08:00
Mohamed Boudra
0ee76b2ea0 Cover timeline epoch reset behavior 2026-05-29 09:55:40 +07:00
Mohamed Boudra
65ace20880 Fix mobile agent timeline catch-up 2026-05-29 09:31:16 +07:00
Mohamed Boudra
2e1b907c63 Improve Playwright E2E test quality (#1210)
* chore(e2e): begin Playwright test-quality pass

* test(e2e): migrate terminal-performance spec onto TerminalE2EHarness

Replace the hand-rolled daemon-client lifecycle (connectTerminalClient +
openProject + createTerminal/navigateToTerminal/killTerminal) with the shared
TerminalE2EHarness, matching terminal-keystroke-stress.spec.ts. The spec now
seeds the workspace and terminals through the harness vocabulary instead of
duplicating setup, cutting the file by ~40 lines with no behavior change.

* test(e2e): extract shared mock-agent workspace helper

Five specs reimplemented the same setup: seed a temp repo, open it as a
project, create a mock-provider agent, then navigate to its workspace route.
Extract seedMockAgentWorkspace + openAgentRoute into helpers/mock-agent.ts and
migrate the rewind-menu and user-message-contract UI-contract specs onto them,
dropping their local getServerId/openAgent/inline-seed duplication.

* test(e2e): migrate terminal tab rename spec onto TerminalE2EHarness

Replace the spec's hand-rolled daemon client setup (connectTerminalClient +
openProject + createTerminal + navigateToTerminal + manual cleanup) with the
shared TerminalE2EHarness and withTerminalInApp helpers, matching the other
terminal specs. Behavior and assertions are unchanged.

* test(e2e): share openProjectViaDaemon across sidebar specs

Both sidebar-workspace and sidebar-workspace-rename rolled their own
identical openProjectViaDaemon helper against the workspace-setup daemon
client. Promote a single shared helper into helpers/workspace-setup.ts
(returning id/name/workspaceDirectory) and have seedProjectForWorkspaceSetup
delegate to it, removing the duplicated open-project logic.

* test(e2e): migrate client-slash-commands spec onto shared mock-agent helper

Replace the spec's hand-rolled openProject/createReadyMockAgent/
openActiveAgentTab/getServerId setup with seedMockAgentWorkspace and
openAgentRoute from helpers/mock-agent, matching the rewind-menu and
user-message-contract specs. Drops ~60 lines of duplicated daemon-client
wiring; the test bodies now read as domain intent.

* test(e2e): converge remaining workspace-setup specs onto shared daemon helpers

Replace the last inline openProject + null-check blocks with the shared
openProjectViaDaemon helper in the file-explorer-collapse and pr-pane specs,
and route workspace-setup-runtime project registration through
seedProjectForWorkspaceSetup. Behavior is identical; the seeding vocabulary now
lives entirely in helpers/workspace-setup.ts.

* test(e2e): migrate composer-autocomplete spec onto shared mock-agent helper

Replace the spec's hand-rolled daemon client setup (connectTerminalClient +
openProject + createAgent + route building + bespoke cleanup) with
seedMockAgentWorkspace + openAgentRoute from helpers/mock-agent, matching the
client-slash-commands migration. Drops the local getServerId, openProject, and
cleanupWithin helpers.

* test(e2e): migrate codex-plan-approval spec onto shared mock-agent helper

Replace the hand-rolled daemon client, project open, agent creation, and
manual route building with seedMockAgentWorkspace + openAgentRoute. The spec
now reads as intent: seed a mock agent, open it, approve the plan, assert the
panel clears.

* test(e2e): converge daemon-client bootstrap onto one shared factory

The five e2e helpers each rolled their own daemon-client connect logic —
duplicating the ws-url resolution, node WebSocket factory, client
construction, and connect call, plus a redeclared config interface.

Extract a single connectDaemonClient factory in daemon-client-loader.ts
that each helper delegates to with its own typed client interface. This
also propagates the port-6767 safety guard (previously only in two
helpers) to all of them, so no test client can target the developer
daemon. Specs are untouched; behavior is preserved.

* test(e2e): promote shared daemon seed client out of terminal-perf helper

The general-purpose E2E daemon client (workspace/agent/terminal seeding and
driving) had grown inside terminal-perf.ts under the name
TerminalPerfDaemonClient, even though most consumers — mock-agent, composer,
rewind-flow, and the launcher/title-handoff specs — have nothing to do with
terminal performance. Move the interface and its connect factory into a
neutrally-named helpers/seed-client.ts (SeedDaemonClient / connectSeedClient)
and point every consumer at it. terminal-perf.ts keeps only its terminal page
helpers. Pure rename/relocate; no behavior change.

* test(e2e): converge E2E_SERVER_ID lookup onto one shared helper

The server-id env accessor was copy-pasted as a local `getServerId`
function in six helpers and five specs (plus a `requireServerId` twin in
the sidebar helper). Extract a single `helpers/server-id.ts` accessor and
route every caller through it, so a new spec imports the vocabulary
instead of re-deriving it. Pure refactor — identical lookup behavior.

* test(e2e): finish converging server-id lookup onto the shared helper

The previous pass extracted helpers/server-id.ts but only routed callers
that wrapped the env read in a local function. Eight specs still re-derived
the lookup inline — some as their own copy-pasted getSeededServerId/
getE2EServerId functions, some as bare `process.env.E2E_SERVER_ID` blocks
inside test bodies. Route them all through getServerId() so a new spec
imports the vocabulary instead of re-deriving it. Pure refactor — identical
lookup behavior; daemon-port reads are left untouched.

* test(e2e): converge workspace seeding onto a shared seedWorkspace helper

Extract the repeated temp-repo + seed-client + openProject bootstrap into
seedWorkspace() in helpers/seed-client.ts, returning a {client, repoPath,
workspaceId, cleanup} handle. seedMockAgentWorkspace and the agent-title-handoff
spec now build on it instead of re-rolling the trio, so the open-project error
handling and teardown live in one place.

* test(e2e): converge launcher-tab spec onto the shared seedWorkspace helper

Replace the hand-rolled createTempGitRepo + connectSeedClient + openProject
trio in beforeAll with seedWorkspace(), and drop the redundant second seed
client in the terminal-title block in favor of the shared workspace.client.

* test(e2e): converge file-explorer-collapse onto the shared seedWorkspace helper

Teach seedWorkspace to forward createTempGitRepo options (files/branches/
remote) so file-seeding specs can drop their hand-rolled
createTempGitRepo + connect + openProjectViaDaemon trio. Migrate
file-explorer-collapse onto it, removing its bespoke WorkspaceSetup client setup.

* test(e2e): converge non-git project setup onto a shared workspace helper

Promote the non-git createTempDirectory out of sidebar-workspace.spec.ts
into helpers/workspace.ts next to createTempGitRepo, and dedupe the
copy-pasted temp-root resolution (workspace.ts, with-workspace.ts, and the
spec) into one shared resolveTempRoot(). The spec drops its low-level
node:fs imports and reads in domain terms.

* test(e2e): converge agent-tab-rename spec onto the shared seedWorkspace helper

Drop the bespoke daemon-client + temp-repo + manual-cleanup trio in
workspace-agent-tab-rename and seed through seedWorkspace, matching the
other converged specs. createIdleAgent now takes a minimal structural
client interface so it accepts either the archive-tab client or the
shared seed client (type-only; the existing archive-tab callers are
unchanged). Verified by running the spec on Desktop Chrome.

* test(e2e): converge pane-remount spec onto the shared seedWorkspace helper

Drop the bespoke archive-tab daemon client + temp-repo + manual-cleanup
trio in workspace-pane-remount and seed through seedWorkspace, matching
the other converged specs. createIdleAgent already accepts the shared
seed client structurally, so the only behavior change is teardown now
goes through workspace.cleanup(). Verified by running the spec on
Desktop Chrome.

* test(e2e): converge sidebar-workspace-rename onto the shared seedWorkspace helper

Expose workspaceName and workspaceDirectory on SeededWorkspace (sourced from
the open-project response) so branch-rename specs can read the resolved branch
name and checkout directory without a bespoke client. Migrate
sidebar-workspace-rename off its hand-rolled connectWorkspaceSetupClient +
createTempGitRepo + openProjectViaDaemon trio, dropping the per-test
client/repo cleanup in favor of seedWorkspace's single cleanup handle.

* test(e2e): converge sidebar-workspace list onto the shared seedWorkspace helper

Migrate all five "Sidebar workspace list" tests off their hand-rolled
connectWorkspaceSetupClient + createTempGitRepo/createTempDirectory +
openProjectViaDaemon trio onto seedWorkspace, collapsing each test's
client/repo cleanup into the single seedWorkspace cleanup handle and dropping
the spec-local setGitHubRemote/execSync machinery.

Two small helper additions make the full file converge:

- seedWorkspace gains a `git: false` option that seeds a plain non-git
  directory (via createTempDirectory) instead of a git repo, so the non-git
  project test gets the same single-handle treatment.
- createTempGitRepo's configureRemote now relabels origin to a display URL
  when both `withRemote` and `originUrl` are given: it sets up the local
  tracking remote, pushes, then `git remote set-url` to the GitHub URL. This
  reproduces the prior withRemote + setGitHubRemote git state exactly (real
  local tracking refs, GitHub origin URL for project grouping) in one fixture
  call, so the GitHub-remote tests are behavior-preserving.

* test(e2e): converge projects-settings onto the shared seedWorkspace helper

Replace the per-fixture connectNewWorkspaceDaemonClient + createTempGitRepo +
openProjectViaDaemon trio with seedWorkspace(), and expose the daemon's
projectId/projectDisplayName on SeededWorkspace so fixtures can read the
project label directly. All 9 specs pass.

* test(e2e): converge composer-attachments onto the shared seedWorkspace helper

Replace the inline connectNewWorkspaceDaemonClient + createTempGitRepo +
openProjectViaDaemon trio in the "composer is locked while new workspace agent
is being created" test with a single seedWorkspace() call, dropping the manual
client.close()/repo.cleanup() teardown in favor of workspace.cleanup(). The
test still passes against a real daemon.

* test(e2e): converge settings-toggle-tab-regression onto the shared seedWorkspace helper

Both tests rolled their own daemon client + temp git repo + manual agent
archive cleanup. Replace that trio with seedWorkspace(), drive idle agents
through workspace.client, and route off workspace.repoPath, leaving cleanup
to workspace.cleanup(). Matches the pattern already used by
workspace-pane-remount and workspace-agent-tab-rename.

* test(e2e): converge workspace-navigation-regression onto the shared seedWorkspace helper

Replace the bespoke connect/openProject/createTempGitRepo/archive trios in
the reconnect, cold-URL, and sidebar-navigation tests with seedWorkspace(),
matching the other migrated specs. Cleanup collapses to workspace.cleanup().

* test(e2e): drop orphaned dead code from agent-bottom-anchor helper

The agent-bottom-anchor spec was removed in a prior cleanup, but its
helper kept a private daemon-client interface and connect fn (duplicating
the shared seed client), seedBottomAnchorAgent, the reply-message builders,
and several scroll helpers that no spec references anymore. Only
readScrollMetrics, expectNearBottom, and waitForContentGrowth are still
used (by agent-stream.ts); keep those and delete the rest.

* test(e2e): converge archive-tab daemon client onto the shared seed client

Fold archiveAgent and fetchAgentHistory into the canonical SeedDaemonClient
and route the archive-tab and sessions-empty specs through connectSeedClient,
deleting the bespoke ArchiveTabDaemonClient wrapper. Both specs only need
general-purpose agent seed/drive operations, so they now share one client
interface instead of re-declaring their own.

* test(e2e): derive workspace-setup daemon client from the real client type

Replace the hand-rolled WorkspaceSetupDaemonClient interface with a
Pick<InternalDaemonClient, ...> over the real daemon client, matching the
pattern already used by the new-workspace helper. The 45-line re-declaration
of RPC method signatures could silently drift from the protocol; deriving it
from the source of truth keeps the test client honest and shrinks the helper.

* test(e2e): converge duplicated escapeRegex onto one shared helper

Seven byte-identical copies of escapeRegex lived across three specs and four
helpers. Extract a single helpers/regex.ts and route every caller through it,
so the suite has one regex-escaping primitive instead of re-declaring the same
pure function per file.

* test(e2e): converge E2E_DAEMON_PORT resolution onto one shared accessor

The isolated test daemon's port was re-read from the environment in ~10
places — three local helper functions in specs, two inline blocks, and
several helper modules — each repeating the "throw if unset" check and,
in the safety-critical paths, the "refuse the developer daemon (6767)"
guard.

Add helpers/daemon-port.ts exporting getE2EDaemonPort(), mirroring
getServerId(), and route every reader through it. The 6767 guard now
applies everywhere: the test port is never legitimately 6767, so
refusing it uniformly keeps every spec off the developer daemon.

While here, route the two inline port-regex escapes through the existing
escapeRegex helper instead of hand-inlining the same pattern.

* test(e2e): capture create-agent cwd via shared WS-frame helper

workspace-cwd's draft-agent test rolled its own request recorder by
monkeypatching WebSocket.prototype.send and stashing frames on a
window global, then reading them back through page.evaluate. Replace
that with captureWsSessionFrames — the same outbound-frame helper four
other specs already use for create_agent_request — so the assertion
reads the cwd directly and the spec drops the browser-side internals
reach-around. No behavior change; the three cwd cases still pass.

* test(e2e): converge daemon WS-route regex onto one shared helper

Five sites rebuilt the Playwright routeWebSocket matcher for the E2E
daemon inline as `new RegExp(`:${escapeRegex(getE2EDaemonPort())}\b`)`
(new-workspace, project-settings, composer-autocomplete, and two in
workspace-navigation-regression), and startup-dsl rolled the same regex
for arbitrary blocked test-host ports. Add daemonWsRoutePattern() and
wsRoutePatternForPort(port) to daemon-port.ts — whose docstring already
promised route patterns live here — and point every site at them.

The emitted regex is byte-identical, so interception behavior is
unchanged; composer-autocomplete still passes. Drops the now-unused
escapeRegex/getE2EDaemonPort imports and the redundant daemonPort locals.
2026-05-29 09:46:15 +08:00
paseo-ai[bot]
23502e474d fix: update lockfile signatures and Nix hash [skip ci] 2026-05-28 17:14:00 +00:00
Mohamed Boudra
b0a0cb4a99 chore(release): cut 0.1.85 2026-05-29 00:09:30 +07:00
Mohamed Boudra
342e92d0c7 Update changelog for 0.1.85 2026-05-29 00:06:48 +07:00
Mohamed Boudra
0170eba233 Add Opus 4.8 to the Claude model picker
Opus 4.8 (and its 1M-context variant) become the default Claude
models, pushing Opus 4.6 off the default slot. Opus 4.7 stays in
the list as the previous release.
2026-05-29 00:03:39 +07:00
Mohamed Boudra
b6103a59da Archive agents on worktree archive; clean up schedules on archive (#1206)
* Archive agents on worktree archive; clean up schedules on archive

Worktree archive used to hard-delete every agent inside it (storage
removed, agent_deleted emitted), losing history along with the worktree.
It now archives those agents instead, so they remain in storage with an
archivedAt timestamp and stay visible in the archived list.

Schedules targeting an archived agent were left as dead records that the
schedule service rejected at run time. AgentManager now fires an
onAgentArchived callback from every archive path (live, stored snapshot,
cascade-to-children), and bootstrap wires it to
ScheduleService.deleteForAgent so those schedules are cleaned up
automatically.

* Push agent_state when archiving stored-only agents; tolerate schedule delete errors

Greptile review on PR #1206 flagged two issues:

1. archiveSnapshot persisted archivedAt to storage but did not emit an
   agent_state event for stored-only agents. Connected clients would
   keep showing them as active until a full reload. Mirror the dispatch
   logic from markRecordArchived: notifyAgentState for live agents,
   dispatchArchivedStoredAgent for off-memory non-internal agents.

2. ScheduleService.deleteForAgent used Promise.all, which short-circuits
   on the first store error and abandons the rest. Switch to
   Promise.allSettled, count fulfilled deletions, and log rejections.

* Tighten agent-manager archive tests after /audit-tests review

- Split "fires onAgentArchived for live, stored, and cascaded archives"
  into two single-behavior tests with independent fixtures, so the assertion
  array isn't reset mid-test.
- Strict equality on the cascade hook assertion (no more arrayContaining).
- "archiveSnapshot dispatches archived state" now captures the full agent
  state event, asserts a dispatch happened, asserts the dispatched agent
  id, and asserts the lifecycle is closed.
2026-05-28 23:02:58 +08:00
Mohamed Boudra
24526a3b23 Move branch-slug to @getpaseo/protocol
The app must not import @getpaseo/server. Moving the slug validation
and slugify helpers into @getpaseo/protocol (already a shared dep of
app, client, server) gives every package a legitimate home for the
import. Drops the corresponding ./utils/branch-slug subpath from
@getpaseo/server's exports map.

Unblocks the Deploy App workflow, which only builds protocol/client/
audio for the app — server's dist never exists in that pipeline.
2026-05-28 18:41:13 +07:00
paseo-ai[bot]
6d1aa1f415 fix: update lockfile signatures and Nix hash [skip ci] 2026-05-28 11:15:56 +00:00
Mohamed Boudra
4b973ae9ec chore(release): cut 0.1.84 2026-05-28 18:11:58 +07:00
Mohamed Boudra
e02e8426d4 Update changelog for 0.1.84 2026-05-28 18:10:30 +07:00
Mohamed Boudra
e4188f5222 Document the two-step release flow
The release has two steps: preparation, which the agent does locally and
reversibly, and go-ahead, which only the user authorizes. Last-minute
changes always need approval, code changes never bundle into the
changelog or release commit, and a sanity-check finding is information
for the user — not a directive for the agent to act on.
2026-05-28 18:08:54 +07:00
Yurui Zhou
8262fb42af Fix/pi ask user submit (#1188)
* Fix Pi ask_user optional input handling

* Clarify Pi ask_user optional comment prompt

* Combine Pi ask_user optional comment UI
2026-05-28 19:08:38 +08:00
Mohamed Boudra
3176f844e7 Make MCP provider controls match the app (#1198)
* Align MCP provider controls with app provider state

* Deepen provider snapshot routing

* Make provider snapshots the daemon authority

* Move provider shutdown behind a generic AgentClient seam

OpenCodeServerManager is now owned entirely by the OpenCode provider.
ProviderSnapshotManager.shutdown() and provider-registry.shutdownProviders()
materialize enabled clients and call an optional shutdown() per client; the
OpenCode client forwards to its runtime. Other providers ignore it.

Also wires providerSnapshotManager into the remaining Session-constructing
tests that were missing it (server-tests CI failure).

* Use platform-native cwd in change-event test
2026-05-28 18:25:09 +08:00
Mohamed Boudra
8234ecb7ba Upload only mac DMGs as workflow artifacts 2026-05-28 16:20:58 +07:00
Mohamed Boudra
adb9a57cfc Build server before desktop app export 2026-05-28 15:50:43 +07:00
paseo-ai[bot]
93c14cb8ad fix: update lockfile signatures and Nix hash [skip ci] 2026-05-28 08:44:09 +00:00
Mohamed Boudra
a00152290f Upload desktop workflow artifacts 2026-05-28 15:40:37 +07:00
Mohamed Boudra
8e4cbf8ca6 Simplify local speech models 2026-05-28 15:40:37 +07:00
Mohamed Boudra
0737c5c973 Stop publishing package debug artifacts 2026-05-28 15:40:36 +07:00
Mohamed Boudra
c4f9874e1f Fix archive redirect route typing 2026-05-28 15:40:36 +07:00
Mohamed Boudra
990bca71b7 Polish blog post metadata 2026-05-28 15:40:36 +07:00
Mohamed Boudra
f431ebee6d Add Electron migration post 2026-05-28 15:40:36 +07:00
Mohamed Boudra
93189148f5 Fix mobile code block rendering 2026-05-28 15:40:36 +07:00
Mohamed Boudra
3baba543a4 Add opencode resume command 2026-05-28 15:40:36 +07:00
Mohamed Boudra
f20393dbb7 Bound workspace git service caches to fix daemon memory leak (#1200)
The seven Map-based caches in WorkspaceGitServiceImpl had no eviction —
the 15s TTL only marked entries stale before refetching in place. Long-
lived daemons accumulated entries for every ephemeral worktree cwd that
ever ran, with checkoutDiffCache holding multi-MB highlighted diffs.

Switch to LRUCache. checkoutDiffCache caps at 64 (heavy values); the
other six aux caches cap at 256. Stale-in-place TTL semantics are
unchanged.
2026-05-28 16:38:50 +08:00
Mohamed Boudra
3ac182cffc Improve app tests (#1197)
* docs(testing): require ports-and-adapters unit tests or real e2e — no in-between

State the end-state explicitly so the test-suite cleanup has a written bar.

* Make useProjects a ports-and-adapters unit test

Extract the per-host workspace aggregation out of useProjects into a
pure fetchAggregatedProjects(input) that takes a typed ProjectsRuntime
adapter. The hook becomes a thin useQuery shim.

The test loses jsdom, @testing-library/react, QueryClientProvider, and
all vi.mock/vi.hoisted of @/runtime/host-runtime. It now exercises the
real aggregation against an in-memory ProjectsRuntime adapter.

* Make useLoadOlderAgentHistory a ports-and-adapters unit test

Extract the load-older sequence into a pure async function that takes
its client, in-flight tracker, toast, and logger as injected
dependencies. The hook reads the session store at call time and wires
the real adapters in. Tests now drive the pure function with typed
fakes — no JSDOM, no @testing-library/react renderHook, no console
spy.

* Make useAgentHistory a ports-and-adapters unit test

Drop the dead __private__ namespace from use-agent-history.ts, expose
fetchAgentHistoryPage and an AgentHistoryClient port as normal exports.
The hook keeps its useInfiniteQuery wiring; the page fetcher is now
testable in isolation.

The test loses jsdom, @testing-library/react, QueryClientProvider, the
vi.hoisted/vi.mock of @/runtime/host-runtime, and the renderHook timing
loops. It exercises the page fetcher directly against an in-memory
AgentHistoryClient adapter that records each call.

* Make usePrPaneData a ports-and-adapters unit test

Extract the timeline fetch into a pure fetchPrPaneTimelinePage that
takes a PrPaneTimelineClient adapter and an UnsupportedTimelineRegistry
port. Extract the rest of the hook's wiring into pure exports:
extractPrRepoIdentity, shouldFetchTimelineFrom, selectPrPaneState. The
module-level unsupported-tuple Set becomes a swappable in-memory
registry; production still wires the same module-level default.

The test loses JSDOM, vi.hoisted + vi.mock of @/runtime/host-runtime,
QueryClientProvider, createRoot, act, focusManager/onlineManager
toggling, and a hand-rolled waitForExpectation polling loop. It drives
each pure function and the timeline fetcher directly against typed
fakes and asserts on recorded state.

* Make useProvidersSnapshot a ports-and-adapters unit test

Extract the network and cache work into pure functions taking a
typed ProvidersSnapshotClient adapter: fetchProvidersSnapshot,
refreshAndApplyProvidersSnapshot, applyProvidersSnapshotUpdate, and
selectorOpenRefetchDecision. The hook still drives them through
react-query and the host-runtime websocket subscription; production
wires the same DaemonClient as before.

The test loses @vitest-environment jsdom, vi.hoisted + vi.mock of
@/runtime/host-runtime, renderHook + QueryClientProvider, act, and a
hand-rolled listener-bucket trap that reached into the spied
client.on subscriber. It drives each pure function directly against
a typed FakeProvidersSnapshotClient and a real QueryClient.

* Make useSettings a ports-and-adapters unit test

Extract the pure load/save logic into use-settings.pure.ts taking a
typed SettingsDeps adapter: a KeyValueStorage port and a
DesktopSettingsBridge port. The hook file keeps the public 0-arg API
(loadAppSettingsFromStorage, loadSettingsFromStorage, persistAppSettings,
saveAppSettings) by wrapping the pure functions with productionDeps that
wire AsyncStorage, isElectronRuntime, loadDesktopSettings, and
migrateLegacyDesktopSettings as before.

The test loses three vi.mock blocks (async-storage, @/desktop/host,
@/desktop/settings/desktop-settings), two vi.hoisted blobs, and the
vi.resetModules + await import("./use-settings") per-test pattern. It
drives the pure functions directly against an in-memory
InMemoryKeyValueStorage and a FakeDesktopBridge that records applied
migrations, asserting on observable storage state rather than mock
function calls.

* Make useAgentCommandsQuery a ports-and-adapters unit test

Extract the daemon call into fetchAgentCommands, a pure async
function taking a typed AgentCommandsClient adapter. The hook still
wraps it in useQuery and resolves the host-runtime client; production
wires the same DaemonClient as before.

The test loses @vitest-environment jsdom, vi.hoisted + vi.mock of
@/runtime/host-runtime, renderHook + QueryClientProvider, and
waitFor. It calls fetchAgentCommands directly against a typed
FakeAgentCommandsClient.

* Make useChangesPreferences a ports-and-adapters unit test

Extract the AsyncStorage-touching load and save logic into
use-changes-preferences.pure.ts taking a typed KeyValueStorage adapter.
The hook file keeps the public API (useChangesPreferences,
loadChangesPreferencesFromStorage) by wrapping the pure functions with a
productionStorage wired to AsyncStorage as before.

The test loses vi.hoisted + vi.mock of
@react-native-async-storage/async-storage, vi.resetModules, and the
per-test dynamic await import("./use-changes-preferences") pattern. It
drives the pure functions directly against an in-memory
InMemoryKeyValueStorage, asserting on observable storage entries rather
than mock function calls. Adds coverage for saveChangesPreferences,
including the no-prior-cache fallback path.

* Make useArchiveAgent a ports-and-adapters unit test

Extract the queryClient-only pure helpers (toArchiveKey,
selectPendingArchiveAgentIds, setAgentArchiving, isAgentArchiving,
removeAgentFromListPayload, markAgentArchivedInHistoryPayload, and the
queryClient-mutating cache helpers) into use-archive-agent.pure.ts. The
hook file keeps the React surface (usePendingArchiveAgentIds,
useArchiveAgent, applyArchivedAgentCloseResults) by importing from the
pure module, and the __private__ reach-around export is gone.

The test loses @vitest-environment jsdom, @testing-library/react, the
renderHook/act/waitFor imports, and the __private__ reach-around. It
calls the pure helpers directly against a real QueryClient and the real
session store. The renderHook test of usePendingArchiveAgentIds is
dropped — it asserted on react-query's subscription mechanics rather
than on our logic.

* Make useSidebarWorkspacesList a ports-and-adapters unit test

Extract the pure pieces — types, applyStoredOrdering,
appendMissingOrderKeys, buildSidebarProjectsFromStructure, and the new
computeSidebarOrderUpdates + deriveSidebarLoadingState helpers — into
use-sidebar-workspaces-list.pure.ts. The hook reads its persistent
sidebar order via the pure helper inside useEffect and derives its
loading state via the pure helper, instead of inlining the logic across
two effects. createSidebarWorkspaceEntry stays in the hook file since
it pulls selectPrHintFromStatus, but the hook re-exports it for
existing callers.

The test loses @vitest-environment jsdom, react-dom/client,
@testing-library/react, and the three Probe components that mounted
React just to assert on effect mechanics. It calls
computeSidebarOrderUpdates and deriveSidebarLoadingState directly, with
no module mocks or hoisted globals. The "does not subscribe while
disabled" assertion is dropped — it was testing useSyncExternalStore's
subscribe gate, not our logic.

* Make useAgentInitialization a ports-and-adapters unit test

Extract ensureAgentIsInitialized and refreshAgent — the entire
imperative bodies of the hook's two callbacks — into
use-agent-initialization.pure.ts, taking setAgentInitializing as an
injected port. Add createSetAgentInitializing as a factory that binds
serverId to the zustand setInitializingAgents action. The hook itself
collapses to ~20 lines of useMemo + useCallback bindings.

The test drops @vitest-environment jsdom, @testing-library/react,
renderHook, and act. It calls ensureAgentIsInitialized and refreshAgent
directly with a bound setAgentInitializing fake. No React mounting, no
module mocks, no hoisted globals.

* Make useClientActivity a ports-and-adapters unit test

Extract the activity-tracker state machine — lastActivityAt bookkeeping,
heartbeat throttling, app-visibility transitions, system-idle monotonic
update, and focused-agent change handling — into
use-client-activity.pure.ts. createClientActivityTracker takes the
heartbeat client, deviceType, and a now() port; the hook wires DOM /
AppState / Electron-idle listeners to tracker methods.

The test drops @vitest-environment jsdom, react-dom/client mounting,
act, and four module mocks (@/constants/platform, @/desktop/electron/idle,
react-native, @getpaseo/client/internal/daemon-client). It calls the
tracker directly with a fake heartbeat client and a test clock; the
fake records emitted heartbeats as observable state.

* Make useHoverSafeZone a ports-and-adapters unit test

Extract the safe-zone state machine — wasInside dedupe, bridge-rect
geometry between trigger and content, and inside/outside transitions —
into use-hover-safe-zone.pure.ts. createHoverSafeZoneTracker takes
getTriggerRect / getContentRect / onEnterSafeZone / onLeaveSafeZone;
the hook just wires document pointermove, window pointerout, and
window blur listeners to tracker methods.

The test drops @vitest-environment jsdom, react-dom/client mounting,
act, @testing-library/react renderHook, the vi.mock("@/constants/platform")
shim, the IS_REACT_ACT_ENVIRONMENT stub, and the getBoundingClientRect
patching helper. It calls the tracker directly with fake rect getters
and asserts on recorded enter/leave counts.

* Make useArchiveSubagent a ports-and-adapters unit test

* Make openImagePathsWithDesktopDialog a ports-and-adapters unit test

Inject the DesktopDialogBridge into openImagePathsWithDesktopDialog instead
of reaching for getDesktopHost() inside the function. useImageAttachmentPicker
passes getDesktopHost()?.dialog at the call site; the native sibling matches
the new signature.

The test drops vi.mock("@/desktop/host", ...) and vi.hoisted() in favor of a
typed in-memory fake dialog that records the options it was called with. No
global module substitution, no spies — the test reads the fake's recorded
state.

* Make useIosHardwareKeyboardSubmit a ports-and-adapters unit test

* Make UpdateCalloutSource a ports-and-adapters unit test

Extract resolveUpdateCalloutDescriptor as a pure function that maps
updater state to a structured callout descriptor. UpdateCalloutSource
becomes a thin React shim that materializes the descriptor's icon and
description as ReactNodes before registering with the sidebar callout
API.

Replaces a 259-line JSDOM + react-dom/client test that mocked five
modules (unistyles theme, lucide icons, async-storage, openExternalUrl,
useDesktopAppUpdater) and mounted SidebarCalloutProvider/Slot just to
assert deterministic title/description/action/dismissal-key derivations.
The new test exercises the resolver directly with zero React, zero DOM,
zero mocks.

* Delete dead useWorkspaceNavigation hook + collapse re-export indirection

The hook had zero production callers — only its own test, which used
vi.hoisted + vi.mock + jsdom + @testing-library/react/renderHook to
verify a useCallback wrapper. The file also re-exported navigateToWorkspace
from the navigation store, so five production importers and two sibling
tests reached the store through a hook-module path that had nothing to
do with hooks.

Retargets every importer to @/stores/navigation-active-workspace-store
directly and removes the indirection module + its slop test.

* Make useCheckoutStatusQuery a ports-and-adapters unit test

Extract peekOrFetchCheckoutStatus and applyCheckoutStatusUpdate to a
sibling checkout-status-cache.ts so both pure functions operate on an
injected QueryClient and CheckoutStatusClient, with no React or host
runtime imports. useCheckoutStatusQuery becomes a thin shell that
composes useQuery + useEffect and delegates the cwd-filter + cache write
to applyCheckoutStatusUpdate.

Replaces a 318-line JSDOM + react-dom/client + fake-timers test that
mocked @/runtime/host-runtime via vi.hoisted, mounted a Probe component
to read the hook's data, and captured the subscription handler in a
hoisted Set. The new test exercises both functions directly against a
real QueryClient — zero React, zero DOM, zero mocks, zero fake timers.

* Make workspace-navigation a ports-and-adapters unit test

* Make navigateToAgent a ports-and-adapters unit test

* Make redirectIfArchivingActiveWorkspace a ports-and-adapters unit test

* Make openProjectDirectly a ports-and-adapters unit test

* Make navigation-active-workspace-store a ports-and-adapters unit test

* Make desktop-attachment-store a ports-and-adapters unit test

* Make readDesktopSystemIdleTimeMs a ports-and-adapters unit test

Rename getDesktopSystemIdleTimeMs to readDesktopSystemIdleTimeMs and
take the desktop IPC invoker as a parameter. The single caller in
use-client-activity.ts now imports invokeDesktopCommand directly and
passes it; the test wires a typed fake invoker.

Drops vi.mock("@/desktop/electron/invoke"), vi.hoisted, and the four
vi.spyOn(console, "warn") log assertions. Tests now assert the
documented behaviour (returns ms, or null) against an in-memory fake.

* Make useDesktopAppUpdater a ports-and-adapters unit test

Extract the check/install state machine from the React hook into a pure
createDesktopAppUpdater runtime that exposes getSnapshot/subscribe/
checkForUpdates/installUpdate. The hook now wires real production deps
into the runtime and bridges its snapshot via useSyncExternalStore;
React-driven concerns (pending-update interval, initial silent check)
stay in the hook.

Drops JSDOM, @testing-library/react, renderHook, the three vi.mock
calls and the vi.hoisted state shim from the test. The new test wires
a typed FakeDesktopAppUpdaterPort recording recordedChecks/recordedInstalls
and exposes deferNextCheck/failNextCheck/nextInstallResult so the
behaviour assertions read as plain English (status transitions through
checking, available, pending, up-to-date, error; race cancellation drops
older results; install errors get reported once).

* Make sidebar-collapsed-sections-store a ports-and-adapters unit test

* Make session-store-hooks a ports-and-adapters unit test

* Make panel-store a ports-and-adapters unit test

* Make workspace-tabs-store a ports-and-adapters unit test

* Make desktop-preview-url a ports-and-adapters unit test

* Make client-id a ports-and-adapters unit test

* Make browser-store a ports-and-adapters unit test

* Make local-file-attachment-store a ports-and-adapters unit test

* Make draft-store a ports-and-adapters unit test

* Make rich-clipboard a ports-and-adapters unit test

* Make desktop-daemon-transport a ports-and-adapters unit test

* Make image-attachment-picker.native a ports-and-adapters unit test

Extract the pure normalize logic into image-attachment-picker.native.pure.ts
and take the PNG exporter as a port. The .native.ts entry wires the real
expo-image-manipulator adapter; the test wires a fake exporter and asserts
on its recorded uris.

Drops vi.mock("expo-image-manipulator") and the inline ImageManipulator
stub from the test. Production callers of normalizePickedImageAssets are
unchanged.

* Make tool-call-icon a ports-and-adapters unit test

Split the pure icon-identity decision into tool-call-icon-name.ts and
keep the lucide/PaseoLogo component lookup in tool-call-icon.ts. The
resolver returns a ToolCallIcon string ("bot", "brain", "paseo", ...);
componentForToolCallIcon does the React component mapping; the existing
resolveToolCallIcon is the composition.

Drops vi.mock("lucide-react-native") from the test and the brittle
"expect(icon).toBe(iconMocks.Bot)" pattern — the unit project couldn't
evaluate lucide-react-native, which is why the mock existed in the
first place. The test now asserts on string identifiers and imports
only the pure module.

buildToolCallPresentation's resolveIcon port is unchanged; the single
caller (components/message.tsx) keeps passing resolveToolCallIcon and
gets the same component back.

* Make desktop-permissions a ports-and-adapters unit test

The test was the heaviest globalThis-juggling test in the app package:
vi.doMock("react-native") + vi.resetModules() per case to swap the
platform, ensureWindow/setNavigator/restoreGlobals to swap
globalThis.Notification, globalThis.navigator, and window.paseoDesktop.
That whole setup existed because the production module reached into
four ambient sources (Notification, navigator, getDesktopHost(),
isWeb/isNative) without a port.

Extract a DesktopPermissionEnvironment interface — { isWeb,
getDesktopHost, getNotification, getNavigator } — and rebuild the
module around a createDesktopPermissions(env) factory. The real
environment binds to the actual globals at module load and the
existing top-level exports (shouldShowDesktopPermissionSection,
getDesktopPermissionSnapshot, requestDesktopPermission) are thin
references onto it, so the only caller (use-desktop-permissions.ts) is
unchanged.

The test now constructs a fakeEnvironment per case and calls into
createDesktopPermissions directly — no JSDOM, no vi.doMock, no
vi.resetModules, no globalThis writes. The eight behaviors are
preserved.

* Make provider-icons a ports-and-adapters unit test

Split the pure provider→icon-identity decision into provider-icon-name.ts
and keep the lucide/SvgXml/catalog component lookup in provider-icons.ts.
resolveProviderIconName returns a ProviderIconName ({kind:"builtin"|"catalog"|"bot", id?}),
and getProviderIcon composes it with the catalog/builtin component maps.

Drops vi.mock("lucide-react-native") from the test — the previous
"expect(icon).toBe(iconMocks.Bot)" pattern only existed because the unit
project couldn't evaluate lucide-react-native. The new test asserts on
discriminated-union identifiers and imports only the pure module.

The 13 outside callers of getProviderIcon are unchanged.

* Make crypto polyfill a ports-and-adapters unit test

* Delete redundant JSDOM Index route test

packages/app/src/app/index.test.tsx mounted the Index component through
JSDOM + createRoot + @testing-library/react, mocked five modules
(expo-router, _layout, desktop-daemon, startup-splash-screen,
navigation-active-workspace-store), and asserted that <Redirect> got
rendered with the right href across six scenarios.

Each of those six scenarios is a one-to-one duplicate of a pure case
already covered in host-runtime-bootstrap.test.ts, which tests the same
two decision functions (resolveStartupRedirectRoute,
resolveStartupWorkspaceSelection) directly. The component is pure
wiring: it reads four hooks, calls the two decision functions, and
renders the result. There is no logic in Index to verify that the pure
tests do not already cover.

Drop the JSDOM file; the wiring is covered by app E2E.

* Make review-draft-store a ports-and-adapters unit test

* Make new-workspace-empty a ports-and-adapters unit test

* Extract shared drag-reorder state machine for web sortable lists

* Extract pure subagents track presentation helpers

Move formatHeaderLabel and resolveRowLabel out of track.tsx into a
colocated track-presentation.ts. Six header-copy tests that previously
mounted React under JSDOM with eight vi.mocks now run as plain unit
tests against the pure helpers.

* Make terminal-file-drop a ports-and-adapters unit test

* Extract pure import-session-sheet view-model helpers

Move resolveProvidersToFetch, buildProviderLabelMap, aggregateSessionEntries,
sumFilteredAlreadyImportedCount, collectErroredProviderLabels, getSessionTitle,
getPromptPreview, and computeEmptyState out of import-session-sheet.tsx into a
colocated import-session-sheet.pure.ts. Add 28 pure unit tests that exercise
provider resolution, dedupe/sort, error label fallback, title fallback, and
the empty-state state machine directly — no JSDOM, no vi.mock.

The existing 859-line JSDOM import-session-sheet.test.tsx stays for now; next
ticks can replace its status-message and empty-state cases with the pure
coverage.

* Make subagents track a ports-and-adapters unit test

Move the row-presentation data builder from track.tsx into the colocated
pure track-presentation.ts and cover statusBucket, titleState, and label
in plain node tests. Delete the 235-line JSDOM track.test.tsx with its
eight vi.mocks and createRoot harness — the remaining behaviours (empty
returns null, useState toggle, onPress wiring) are React idioms covered
by the framework, not domain logic.

* Make isolated-bottom-sheet-modal a ports-and-adapters unit test

* Stop UI from leaking through subagents barrel

The subagents/index.ts barrel re-exported SubagentsTrack (the React
component) alongside pure logic like selectSubagentsForParent. Any pure
consumer of the barrel transitively pulled in lucide-react-native and
react-native-unistyles, forcing tests that only touch pure logic to
declare cosmetic vi.mock blocks for icons, theme, tooltip, and provider
icons just to get the import graph to load.

Drop SubagentsTrack from the barrel. The one external caller
(panels/agent-panel.tsx) deep-imports from @/subagents/track instead.
The workspace-subagents-integration test loses 56 lines of pretend-UI
mocks; only the AsyncStorage mock remains because workspace-layout-store
uses it directly through persist middleware.

* Collapse use-hover-safe-zone.pure.ts into hover-safe-zone-tracker.ts

The `.pure.ts` suffix mimics tooling-resolved variants (`.web.ts`,
`.native.ts`, `.test.ts`) without being one. The file exports a single
tracker; rename it after its role. Test sits next to its subject by name.

* Promote use-settings to a directory module

* Promote sidebar-collapsed-sections-store to a directory module

* Promote navigate-to-agent to a directory module

The `.pure.ts` filename suffix mimics tooling-resolved variants
(`.web.ts`, `.native.ts`, `.test.ts`) without being one. Move the pure
resolver and its wired wrapper into `utils/navigate-to-agent/`, where
the directory carries the domain and `resolve.ts` names the role. The
pure function is renamed `resolveNavigateToAgent` so it no longer
collides with the wrapper's exported `navigateToAgent`.

* Collapse use-client-activity.pure.ts into client-activity-tracker.ts

* Collapse open-project.pure.ts into open-project.ts

* Promote use-changes-preferences to a directory module

Replaces the .pure.ts / .test-utils.ts double-suffix with a
directory home that mirrors hooks/use-settings/:

  hooks/use-changes-preferences/
    index.ts          ← React hook; wires AsyncStorage
    storage.ts        ← pure load/save
    storage.test.ts   ← tests against storage
    fakes.ts          ← in-memory KeyValueStorage adapter

External callers continue importing @/hooks/use-changes-preferences
unchanged (resolves to index.ts).

* Promote draft-store to a directory module

* Collapse use-archive-agent.pure.ts into use-archive-agent.ts

* Rename import-session-sheet.pure.ts to import-session-sheet-view-model.ts

* Rename image-attachment-picker.native.pure.ts to picked-image-normalizer.ts

* Inline use-agent-initialization.pure.ts into use-agent-initialization.ts

* Rename use-sidebar-workspaces-list.pure.ts to sidebar-workspaces-view-model.ts

* Rename review/store.pure.ts to review/state.ts

* Promote browser-store to a directory module

* Promote navigation-active-workspace-store to a directory module

* Promote panel-store to a directory module

* Promote session-store-hooks to a directory module

* Promote workspace-tabs-store to a directory module

* Rename use-archive-subagent.pure.ts to archive-subagent.ts

* Rename sidebar-workspace-archive-redirect.pure.ts to workspace-archive-redirect.ts

* Rename workspace-navigation.pure.ts to prepare-workspace-tab.ts
2026-05-28 12:34:31 +08:00
paseo-ai[bot]
5cede0a7bb fix: update lockfile signatures and Nix hash [skip ci] 2026-05-27 18:01:44 +00:00
Mohamed Boudra
53c14d9855 Extract client SDK package (#1052)
* Extract client SDK package

* Polish SDK client identity defaults

* Build client before dependent CI jobs

* Restore daemon client server export

* Extract protocol and client SDK packages

* Fix provider override schema validation

* Fix app test daemon client imports

* Simplify workspace build targets

* Fix CLI test server build bootstrap

* Run SDK package tests in CI

* Fix rebase package split drift

* Restore lockfile registry metadata

* Update SDK config test for prompt default

* Move terminal stream router test to client package

* Fix rebase drift for protocol imports

* Fix SDK agent capability fixture

* Restore legacy server client exports

* Fix server export compatibility test

* Advertise custom mode icon client capability

* Remove server daemon-client exports

* Format rebased mode control import

* Fix rebase drift for protocol imports

Files added by upstream PRs (#893, #1147, #1154) referenced the pre-split
shared/ paths that this branch moves into @getpaseo/protocol. Redirect
those imports to the protocol package so typecheck stays green after the
rebase.
2026-05-28 01:58:18 +08:00
Mohamed Boudra
0ea41378a4 Fix provider binary diagnostics for command overrides (#1191)
* fix: align provider binary diagnostics

* Fix provider launch tests on Windows

* Separate provider launch availability checks
2026-05-28 00:54:43 +08:00
Mohamed Boudra
00759e7994 Fix provider-selection test for synthetic default row
The selector rework now synthesizes a "Default" row for ready enabled
providers without explicit models, so they share the same `kind: "models"`
shape as providers with real models. Updated the stale assertion that
still expected the old `providerDefault` kind.
2026-05-27 23:14:33 +07:00
Mohamed Boudra
e3eb333ddc Render opencode Edit tool calls as diffs
opencode's Edit tool sends camelCase input keys (filePath, oldString,
newString) and a plain-string success ack on output. The shared edit
schemas expect snake_case input and an object output, so calls fell
through to the generic "unknown" renderer (raw JSON Input/Output).

Normalize both at the opencode boundary so the quirk does not leak into
the cross-provider schema.
2026-05-27 22:43:08 +07:00
Mohamed Boudra
a025f17a73 Fix workspace git service tests for facts optimization
Tests stubbed the old dep set and asserted on call signatures
that no longer match after the getCheckoutSnapshotFacts work was
threaded through refresh/observation/getPullRequestStatus.
2026-05-27 22:29:59 +07:00
Mohamed Boudra
a4cb7431d8 Rework provider selector and settings UX
Surface all enabled providers in the model selector with inline loading
and error states instead of silently hiding non-ready ones. Tap any row
to drill in; loading and error states show in the drill-down body, with
a Retry button on error. Providers with no models render a synthetic
"Default" row so every provider's drill-down has symmetric structure.

Redesign the per-provider settings sheet: search lives in the header,
discovered and custom models share one scrolling list, and the footer
hosts action buttons. Diagnostic and "Add model" each open as their own
sub-sheet, so neither expands inline or shifts the surrounding layout.

Snapshot refresh now keeps cached data visible: setQueryData replaces
the cache atomically and sibling-scope caches are invalidated (not
removed), so refreshing a provider no longer blanks its row. Search
inputs across the app are uncontrolled — onChangeText drives parent
state, and resetKey remounts the field when an explicit reset is needed
— to avoid RN's controlled-input flicker.

Adds a dev-only mock-slow provider whose discovery never resolves, so
the 30s snapshot timeout produces a real error and lets loading and
timeout-error UI be exercised end to end.
2026-05-27 22:00:34 +07:00
Mohamed Boudra
5696cdb455 refactor(server): integrate session MCP command stack (#893)
* Extract permission response command

* Extract create agent command

* Extract agent lifecycle commands

* refactor(server): extract worktree archive command

* refactor(server): share worktree create list commands

* test(server): update close items lifecycle fakes

* Fix MCP command stack CI regressions

* Restore MCP update_agent no-op success semantics

After the rebase, MCP `update_agent` was returning `success: false` for empty/no-op calls (no name, no labels, no settings). Origin/main returned `success: true` unconditionally. The audit flagged the change as out of scope for this stack — restore the old behavior at the MCP boundary.

Session WS path keeps its accepted/rejected semantics (it surfaces an error when nothing was provided so the client can prompt the user).

* Fix agent metadata delegation and mode persistence

* Update MCP update_agent test for metadata delegation

* Fix stored agent metadata timestamps
2026-05-27 22:43:03 +08:00
Mohamed Boudra
5a56835db3 Add startup diagnostics reports 2026-05-27 20:59:06 +07:00
Mohamed Boudra
698d549983 Title-case agent landing page meta titles 2026-05-27 20:52:33 +07:00
Mohamed Boudra
fa1b3e88f0 Reuse git facts for workspace observation 2026-05-27 20:33:21 +07:00
Mohamed Boudra
5d8dc800fc Reduce startup git work for workspace snapshots 2026-05-27 20:18:49 +07:00
Mohamed Boudra
dbfd42da46 Remove Reddit icon from site header 2026-05-27 19:10:55 +07:00
Mohamed Boudra
2894917a1c Add Reddit link to website and README 2026-05-27 19:01:17 +07:00
Mohamed Boudra
9c2d47ab34 Deploy website for docs changes 2026-05-27 16:17:10 +07:00
Mohamed Boudra
d787aefa4c Fix OpenCode MCP injection on wildcard binds
Wildcard listen addresses are bind targets, not client endpoints. Inject loopback for agent MCP URLs and surface OpenCode MCP add failures returned in data payloads.
2026-05-27 16:01:06 +07:00
Mohamed Boudra
8307a0ca6f docs: add Codex custom OpenAI-compatible endpoint example
Promote the brief Codex env-var note into a dedicated section next to
the Z.AI and Qwen examples, showing exactly which env vars Paseo wires
into Codex's model_providers thread config and how base_url, wire_api,
env_key, and requires_openai_auth are derived.
2026-05-27 15:57:34 +07:00
Mohamed Boudra
7aa49b2905 Add SEO landing pages 2026-05-27 13:40:49 +07:00
Mohamed Boudra
d594bce153 Rank slash command autocomplete matches 2026-05-27 13:40:49 +07:00
Mohamed Boudra
a630986d06 Align user message footer controls 2026-05-27 13:40:49 +07:00
paseo-ai[bot]
724a499413 fix: update lockfile signatures and Nix hash [skip ci] 2026-05-27 04:59:03 +00:00
Li Mu Zhi
8aa1530be1 fix(app): native iOS text selection in assistant messages via UITextView
Fixes #238
Fixes #648
Refs #21
2026-05-27 04:55:46 +00:00
Mohamed Boudra
1908ab8765 Move context ring to footer right edge on compact 2026-05-27 11:13:23 +07:00
Mohamed Boudra
5ad6cff039 Drop color treatment from agent mode controls 2026-05-27 10:56:31 +07:00
Mohamed Boudra
8a463c55c8 Update daemon hello capability test 2026-05-27 09:59:58 +07:00
Mohamed Boudra
aecb300073 Stabilize OpenCode provider unit tests 2026-05-27 09:51:40 +07:00
Mohamed Boudra
4911b627f2 Treat agent mode icons as open strings
Old clients pinned AgentModeIcon to a closed enum and rendered
undefined for unknown values, crashing the route in production
Hermes with no stack trace. Loosen the type to a plain string,
let the client skip rendering when an icon is unregistered, and
add the customModeIcons capability so the daemon downgrades
non-legacy icons to ShieldCheck for clients that pre-date the
open-string contract.
2026-05-27 09:30:50 +07:00
Mohamed Boudra
a5b82d2a3b Relax MCP tool output validation 2026-05-26 23:22:09 +07:00
Mohamed Boudra
09bf981f13 Reshape stream head/tail spacing 2026-05-26 23:01:42 +07:00
Mohamed Boudra
3cf92ad6c5 Merge branch 'main' of github.com:getpaseo/paseo 2026-05-26 22:35:38 +07:00
Mohamed Boudra
153fa42a95 Tighten markdown list spacing 2026-05-26 22:27:18 +07:00
Mohamed Boudra
6d205f8853 Add OpenCode auto accept feature 2026-05-26 21:51:04 +07:00
paseo-ai[bot]
5468089ac0 fix: update lockfile signatures and Nix hash [skip ci] 2026-05-26 13:54:15 +00:00
Mohamed Boudra
0ab41fbd9a chore(release): cut 0.1.83 2026-05-26 20:50:25 +07:00
Mohamed Boudra
ed6caa11c0 Update changelog for 0.1.83 2026-05-26 20:49:15 +07:00
Mohamed Boudra
74c8942a28 Confirm create-agent start before returning
Wait for the initial background turn to actually start before create returns a snapshot, without waiting for completion.

Freshly created agents now surface start failures as create failures, and both the WS/client and MCP surfaces reflect the shared create-agent lifecycle helper.
2026-05-26 20:38:27 +07:00
Mohamed Boudra
3eb1ba7d73 Narrow MCP schedule cadence handling to blank values 2026-05-26 20:15:12 +07:00
Mohamed Boudra
1d2c8b1648 Fix MCP schedule cadence placeholder handling
Treat undefined, blank, whitespace-only, and /__omit__ cadence values as absent at the MCP boundary for schedule create and update.

That keeps placeholder inputs from tripping cadence validation while preserving the real invalid cases for conflicting or missing cadence values.
2026-05-26 20:15:11 +07:00
Mohamed Boudra
88914ccba6 Fix draft mode chip for non-thinking models
Keep the desktop controls row alive when desktop extras exist, so draft mode chips still render even when a model has no thinking options.\n\nRestore the replacement draft e2e assertion for the mode chip now that it renders again.
2026-05-26 20:15:11 +07:00
paseo-ai[bot]
f79101a1f0 fix: update lockfile signatures and Nix hash [skip ci] 2026-05-26 10:13:47 +00:00
Mohamed Boudra
b492c70825 chore(release): cut 0.1.82 2026-05-26 17:09:51 +07:00
Mohamed Boudra
3ace6c5602 Defer slash-clear draft mode assertion to follow-up
After the composer refactor the replacement draft's mode picker
stopped rendering because the draft's provider snapshot does not
surface modes for the running provider. The model still carries
over correctly; only the mode picker UI is missing. Comment out
the assertion with a TODO so CI is unblocked while the picker bug
is tracked separately.
2026-05-26 16:47:27 +07:00
Mohamed Boudra
be7979fb34 Update slash clear test to expect formatted mode label
The composer refactor changed the agent-mode button's accessibility
label from the raw mode id ("load-test") to the formatted label
("Load test"). The user-facing change is intentional. Bring the test
in line with the new label.
2026-05-26 16:25:24 +07:00
Mohamed Boudra
58e7fd0e3e Close rewind menu when the action errors
handleSelect only ran setIsOpen(false) on the success path. When the
rewind action failed, the catch block swallowed the error but the menu
stayed open, blocking subsequent interactions with the page.

Close in finally so success and error both dismiss the menu.
2026-05-26 15:37:17 +07:00
Mohamed Boudra
81407c5ffc Derive tab testIDs from the tab target
The tab testID was reading from tabId, which intentionally stays as the
draft id after a draft -> agent handoff so the panel React key remains
stable and the component does not remount. The data-testid was riding
along on that identity and stayed workspace-tab-draft_xxx forever, even
though the tab semantically became an agent. Tests that look for
workspace-tab-agent_* timed out.

Read testIDs from tab.target via buildDeterministicWorkspaceTabId so the
testID reflects what the tab represents. React key is unchanged.
2026-05-26 15:37:08 +07:00
Mohamed Boudra
9c5f6007be Update changelog for 0.1.82 2026-05-26 15:04:41 +07:00
Mohamed Boudra
fab721205d Fix autocomplete popover render warning 2026-05-26 14:51:43 +07:00
Mohamed Boudra
e74c7f9554 Show OpenCode agent colors in modes 2026-05-26 14:36:45 +07:00
Mohamed Boudra
4231dbbba4 Organize composer into its own module 2026-05-26 14:32:45 +07:00
Mohamed Boudra
7e792e899a Antialias web app fonts 2026-05-26 12:56:38 +07:00
Mohamed Boudra
1d38aacf5c Fix OpenCode replacement prompts after interrupt 2026-05-26 11:54:36 +07:00
Mohamed Boudra
a3071b46a4 Fix optimistic user message reconciliation 2026-05-26 11:37:13 +07:00
Mohamed Boudra
483790d8c9 Replay completed tool results from provider history 2026-05-26 11:36:27 +07:00
Mohamed Boudra
bde0efc9b0 Stop rereading provider snapshots while loading 2026-05-26 11:35:20 +07:00
Mohamed Boudra
6f16900b8a Hide paseo-system prompts from the agent timeline
System-injected prompts (finish notifications, schedule fires) are wrapped
in a <paseo-system> envelope and dispatched as regular user messages to
the provider. The app was showing them as user-message turns. Detect the
envelope at the manager boundary and skip them during live runs and
history replay so only real user input renders.
2026-05-26 11:28:22 +07:00
Mohamed Boudra
c31ab074e9 Fix autocomplete popover anchoring
Position portal content relative to its render host so the composer anchor stays correct when surrounding layout shifts. This preserves the Portal surface needed for Android scrolling and touch behavior.
2026-05-26 11:17:03 +07:00
Mohamed Boudra
bf69ebeddf Stop persisting agent prompt as the backend title
config.title is now strictly the caller's creation intent and is never
mutated. The prompt-derived placeholder lives only in record.title at
creation, so the metadata generator's upfront gate (hasExplicitTitle) is
the only one and writes unconditionally when it runs. Drops the two
projection fallbacks to record.config.title that were the second
hidden path for the prompt to surface as a title.
2026-05-26 10:42:36 +07:00
Mohamed Boudra
22e014aaff Fix mobile autocomplete sidebar layering 2026-05-26 10:02:27 +07:00
Mohamed Boudra
91b05b4111 Fix autocomplete popover flicker 2026-05-26 09:35:28 +07:00
Mohamed Boudra
6e56ee9e32 Fix terminal color query replies
Answer OSC color queries from the daemon and suppress browser-side replies so response bytes do not race back into the shell prompt as user input.
2026-05-26 09:32:15 +07:00
Mohamed Boudra
48d4e3e408 Reduce draft tab descriptor updates 2026-05-25 21:37:04 +07:00
Mohamed Boudra
30a8bffc24 Show optimistic chat for new workspaces 2026-05-25 21:26:31 +07:00
Mohamed Boudra
d271597ee8 Clean up read tool activity content 2026-05-25 21:08:11 +07:00
Mohamed Boudra
893dd6d1fe Fix write tool activity mapping 2026-05-25 20:59:44 +07:00
Mohamed Boudra
53faba64ca Fix draft agent handoff continuity
Move optimistic user message reconciliation into the stream reducers and keep draft-created agents in a handoff lifecycle until authoritative history lands.
2026-05-25 20:53:49 +07:00
Mohamed Boudra
e5658654c0 Open rewind in an anchored dropdown menu
Replace the full-screen rewind sheet with a dropdown anchored to the
trigger, with a muted warning header and inline pending state on the
chosen action. Also realign the user message action row so timestamp,
rewind, and copy share a single 24px baseline — the copy button was
inheriting assistant-footer paddings that pushed the row taller than
the icons.
2026-05-25 20:18:57 +07:00
Mohamed Boudra
f0730d5eac Polish compact workspace header actions 2026-05-25 19:50:31 +07:00
Mohamed Boudra
e312e0d6f3 Focus mobile terminal only on tap 2026-05-25 19:27:45 +07:00
Mohamed Boudra
9806a893ed Improve mobile terminal keyboard layout 2026-05-25 19:26:38 +07:00
Mohamed Boudra
5dd2afaac9 Hide terminal keyboard when opening sidebars 2026-05-25 19:26:38 +07:00
Mohamed Boudra
2252046f56 Fix mobile terminal focus and background 2026-05-25 19:26:25 +07:00
Mohamed Boudra
423956c6a0 Preserve draft composer permission mode (#1175) 2026-05-25 19:19:44 +08:00
Mohamed Boudra
19289286a6 Accept dropped files in the terminal (#1173) 2026-05-25 19:16:39 +08:00
Mohamed Boudra
e40ad0c00e Link terminal file paths to workspace previews (#1174) 2026-05-25 19:12:43 +08:00
Mohamed Boudra
1e68283565 Allow PR merge when GitHub reports it ready (#1172) 2026-05-25 10:05:35 +00:00
ElliotWu
8a2e5c786e fix(app): swallow URIError in assistant file link parser on bare '%'
decodeURIComponent throws on '%' not followed by two hex digits. The two
calls inside parseAssistantFileLink and normalizeFileUrlPath had no
try/catch, so any timeline message containing strings like '100% packet
loss' (ping output), '%PATH%' or '0% off' could escape into the React
tree and unmount the agent route, leaving the renderer blank.

Wrap both calls in a local safeDecodeURIComponent that returns the input
unchanged when decoding fails, mirroring the helper already used in
host-routes.ts.

Closes #1148
2026-05-25 10:05:00 +00:00
Mohamed Boudra
178708a44e Show OpenCode tool activity consistently (#1171)
* fix(server): map OpenCode tool details

* test(server): update OpenCode history client stub
2026-05-25 18:04:33 +08:00
Mohamed Boudra
05eec04b10 Format settings latency readouts (#1170) 2026-05-25 17:47:31 +08:00
ayhan malkoc
b7ea5b4c9d Show workspace scripts on mobile header (#1093) 2026-05-25 17:45:48 +08:00
Alcimério Rangel
655d05add8 Add Devin CLI to the ACP provider catalog 2026-05-25 17:38:56 +08:00
Mohamed Boudra
f137705f1e Fix provider models per workspace (#1167)
* Scope provider snapshots by workspace

* Fix provider snapshot cwd test on Windows
2026-05-25 17:12:15 +08:00
Mohamed Boudra
6b35c0a640 Add persistent OpenCode permission actions (#1168) 2026-05-25 17:11:53 +08:00
Mohamed Boudra
a91dfb63c6 Treat OpenCode abort as cancellation, not error (#1169)
OpenCode emits session.error with MessageAbortedError when the user
interrupts a running turn. Paseo was mapping every session.error to
turn_failed, which set the agent lifecycle to error and appended a
raw JSON system error message to the timeline.

Now MessageAbortedError translates to turn_canceled (same as an
explicit cancel), so the agent returns to idle with no error state.
Real errors still produce turn_failed.
2026-05-25 17:11:44 +08:00
Mohamed Boudra
132e572d6e Rewind chat or files from any user message (#1154)
* Add Pi extension-launch plumbing for in-process tree primitives

* Add Rewind controls for agent sessions

* Preserve SDK checkpoint env var so Claude file rewind works

The SDK injects CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING=true when file checkpointing is enabled. The earlier parent-env stripping from c4782ec71c was too broad and removed that child flag along with real parent-session markers.

Keep stripping parent markers, but preserve the checkpoint flag so the SDK rewind path can run directly. With that boundary fixed, the JSONL file-rewind fallback is no longer needed.

* Update OpenCode history replay tests for rewind state

* Show Rewind actions in an adaptive sheet

* Fix rewind semantics across providers

* Refactor Pi IDs through extension capture

* Restore user message to composer after rewind

* Fix Claude rewind tool result mapping

* Fix Claude rewind session switch roundtrip

* Fix rewind and optimistic message regressions

* Tighten rewind test discipline

* Fix OpenCode optimistic message reconciliation

* Reconcile optimistic user messages by marker

* Reorder user message actions

* Unify provider user message contract

* Add Claude rewind flow Playwright contract

* Add Codex rewind flow Playwright contract

* Add OpenCode rewind flow Playwright contract

* Add Pi rewind flow Playwright contract

* Remove real-provider rewind Playwright contracts

* Gate real-provider rewind specs from CI Playwright
2026-05-25 17:11:34 +08:00
Mohamed Boudra
e966f70322 Merge remote-tracking branch 'origin/main' 2026-05-25 12:19:49 +07:00
Mohamed Boudra
62780448dc refactor(app): organize agent stream rendering 2026-05-25 12:15:20 +07:00
Mohamed Boudra
5755ca77f8 Show cumulative session cost (#1163) 2026-05-25 12:13:37 +08:00
Mohamed Boudra
ba724956df feat(website): serve /llms.txt and raw markdown for docs
Adds an llms.txt at the site root with a static product preamble so
LLMs can answer "what is Paseo" without following any links, plus a
curated index of docs, alternatives, and the 38 agent pages. Doc pages
now expose Copy/View as markdown buttons and a raw .md version at
<path>.md following the llms.txt convention.

Also skips the canonical-host redirect for localhost so the dev server
stops punting every request to production.
2026-05-24 21:38:33 +07:00
paseo-ai[bot]
7fbbb44ad6 fix: update lockfile signatures and Nix hash [skip ci] 2026-05-24 06:16:43 +00:00
Mohamed Boudra
4c97ff8fa0 chore(release): cut 0.1.81 2026-05-24 13:13:01 +07:00
Mohamed Boudra
c0d2f20056 Update changelog for 0.1.81 2026-05-24 13:11:48 +07:00
Mohamed Boudra
2a3cfc684b Merge branch 'main' of github.com:getpaseo/paseo 2026-05-24 12:59:53 +07:00
Mohamed Boudra
68c893f643 fix(server): inject OpenCode MCP servers once 2026-05-24 12:58:30 +07:00
Mohamed Boudra
d43d30eeeb fix(server): log each provider fallback during metadata generation 2026-05-24 12:54:08 +07:00
Mohamed Boudra
79dcbdc1c1 Fix false unpushed commit warnings for worktree archive (#1158)
* fix(server): avoid false unpushed worktree commits

* fix(server): preserve local-only worktree push counts
2026-05-24 12:41:36 +08:00
paseo-ai[bot]
94bccf19ba fix: update lockfile signatures and Nix hash [skip ci] 2026-05-24 04:13:18 +00:00
Mohamed Boudra
83f205bd3d Preserve assistant message formatting on copy 2026-05-24 04:10:09 +00:00
Mohamed Boudra
846c9b9da3 Make mobile terminals load faster (#1147)
* Speed up terminal restores on mobile

* Preserve terminal output bytes in the app
2026-05-23 18:41:10 +08:00
paseo-ai[bot]
84f1dfc8cb fix: update lockfile signatures and Nix hash [skip ci] 2026-05-23 10:25:37 +00:00
Mohamed Boudra
33892f0698 fix(app): stop terminal flicker on resize 2026-05-23 17:22:24 +07:00
Mohamed Boudra
0962529d48 Make the web app installable (#1144)
* feat(app): make web app installable

* fix: restore CI for import and shortcut tests
2026-05-23 12:17:51 +08:00
Mohamed Boudra
008e4e846f fix(app): make slash command popover interactive on Android
The popover was positioned `bottom: 100%` of its parent inside the composer
and worked on iOS/web; Android's hit-test by parent bounds drops touches on
children that overflow their parent, so taps and scrolls went through to the
chat behind it. Move the popover into a `<Portal>` at the app root and apply
the same Reanimated keyboard SharedValue as the composer so it tracks the
keyboard in lockstep. New `docs/floating-panels.md` captures the gotchas
(Android hit-test, Portal lifecycle/transforms, status-bar offset, the
two-measurement flash) and the canonical files.
2026-05-22 20:44:37 +07:00
Mohamed Boudra
789a559b31 fix(app): make sheet header search text readable in dark mode
The model picker search input rendered text in the light-theme
foreground color on a dark surface, leaving typed characters invisible.

@gorhom/bottom-sheet mounts header subtrees before the sheet is visible
and keeps them mounted across theme changes, so any color read from
StyleSheet.create((theme) => ...) at the caller goes stale. Move text
color and placeholder color into AdaptiveTextInput itself via
withUnistyles, with the leaf's color appended last so callers cannot
re-introduce a stale read. Drop the now-redundant placeholderTextColor
props at the header search call sites.

Also ban useUnistyles() in docs/unistyles.md. The hook subscribes to
every runtime change and forces a re-render each cycle; new code uses
withUnistyles or StyleSheet.create instead.
2026-05-22 18:23:11 +07:00
Mohamed Boudra
f5f1ae7fa9 fix(app): show one /exit row with /quit and /q as aliases 2026-05-22 18:22:19 +07:00
Mohamed Boudra
8e0ebfcaaa fix(server): hide empty sessions from the import list 2026-05-22 18:12:15 +07:00
Mohamed Boudra
b151dfcfd5 fix(app): show segmented control track under all segments 2026-05-22 18:05:35 +07:00
Mohamed Boudra
de7bf2fb01 fix(app): restore Alt+letter shortcuts on macOS
The key matcher only consulted event.code when a binding explicitly
opted in via codeFallback. macOS rewrites event.key whenever Option
is held (Option+T -> "†", Option+[ -> "“", Option+Shift+W -> "„",
etc.), so the comparison against combo.key always failed and every
Alt-bound letter / bracket shortcut silently stopped firing on Mac:
Cmd+Alt+T (cycle theme), Alt+Shift+[/], Alt+[/], Alt+Shift+W.

Fall back to event.code when combo.alt is set. Keep the existing
key-first behaviour for non-Alt bindings so Dvorak users still get
logical-character matching (Cmd+V on physical Period keeps pasting
instead of triggering Cmd+. for sidebar toggle).
2026-05-22 17:55:48 +07:00
Mohamed Boudra
5f11f602fb feat(app): add community links and sidebar home button 2026-05-22 17:40:42 +07:00
Mohamed Boudra
369c5a4498 fix(app): show shortcut chord badges in light mode
The Shortcut component computed its colors with a hex-only opacity
helper that received unistyles' CSS-var theme strings on web (e.g.
"var(--colors-foreground)"), fell through both regex branches, and
returned the rgba(255,255,255,...) fallback. Badges and chord text
rendered as near-white on white, so the keyboard shortcuts settings
page looked blank. Use the existing surface2 and foregroundMuted
tokens directly, which work in both themes without per-platform
color math.
2026-05-22 17:28:58 +07:00
Mohamed Boudra
c46ff2e045 feat(app): native terminal on WebView with xterm.js
Replace the Expo DOM-backed terminal on iOS and Android with a managed
WebView running xterm.js. Web and desktop continue to use the existing
DOM implementation.

Mobile mounted tabs switch from display:none to opacity:0 for the hidden
slot on purpose: terminals stay mounted under the LRU tab cache, and
keeping the WebView in the layer tree avoids cold-starting it every time
the user returns to a terminal tab.

EAS native builds rebuild the WebView bundle post-install so the
generated HTML stays in sync with the entry source.

This does not fix the WS 1006 / NSPOSIXErrorDomain Code=54 host-disconnect
symptom that prompted the investigation. After restoring the baseline,
that symptom could not be replicated; do not read this commit as its
root cause or fix.
2026-05-22 15:33:58 +07:00
Yurui Zhou
af10e64f82 feat(pi): bridge extension UI dialogs (#1134) 2026-05-22 00:03:42 +08:00
paseo-ai[bot]
db44a3e0e1 fix: update lockfile signatures and Nix hash [skip ci] 2026-05-21 13:06:14 +00:00
Mohamed Boudra
25c4cee01e chore(release): cut 0.1.80 2026-05-21 20:02:05 +07:00
Mohamed Boudra
3ec4e2c536 docs(changelog): draft 0.1.80 entry 2026-05-21 20:00:36 +07:00
Mohamed Boudra
7dd9cbc506 fix(app): keep inline style marker web-only 2026-05-21 19:58:31 +07:00
paseo-ai[bot]
6dc22483c5 fix: update lockfile signatures and Nix hash [skip ci] 2026-05-21 10:11:01 +00:00
Mohamed Boudra
a9f5b8ea4d chore(release): cut 0.1.79 2026-05-21 17:03:25 +07:00
Mohamed Boudra
1ce30bac05 docs(changelog): draft 0.1.79 entry 2026-05-21 16:51:15 +07:00
Mohamed Boudra
754f5a1f0b Tweak import session tile copy 2026-05-21 16:27:49 +07:00
Mohamed Boudra
698390bf1c feat(app): copy resume command for pi agents 2026-05-21 16:23:23 +07:00
Mohamed Boudra
63b24ec261 Merge branch 'main' of github.com:getpaseo/paseo 2026-05-21 16:19:04 +07:00
Mohamed Boudra
ad060e2f86 feat: import existing CLI sessions from the home screen
Home screen now leads with action tiles (Add a project, Import session,
Setup providers, Pair device) instead of a single button, giving new
users a clear path in even before they've added a project.

Import session is the onboarding hook: the sheet now works without a
workspace context and lists recent sessions from every provider's native
history (Claude, Codex, OpenCode, Pi, ACP). Picking one imports the
agent and lands the user on its workspace.

To make that work end-to-end, the daemon upserts the imported agent's
cwd as a workspace so the sidebar surfaces it even when the user never
explicitly opened the folder.

Also adds a Home entry to cmd+K and a refresh button to the import
sheet header for explicit re-fetch.
2026-05-21 16:15:28 +07:00
paseo-ai[bot]
1e595ad9cb fix: update lockfile signatures and Nix hash [skip ci] 2026-05-21 08:21:38 +00:00
Mohamed Boudra
9fd93f8308 Merge remote-tracking branch 'origin/main' into release-beta-0-1-79-beta-3 2026-05-21 15:17:35 +07:00
paseo-ai[bot]
fac81f568a fix: update lockfile signatures and Nix hash [skip ci] 2026-05-21 05:51:50 +00:00
Mohamed Boudra
57de8c3807 chore(release): cut 0.1.79-beta.3 2026-05-21 12:50:43 +07:00
Mohamed Boudra
0310fd3c3c Merge branch 'main' of github.com:getpaseo/paseo 2026-05-21 12:48:30 +07:00
Mohamed Boudra
a837d3e5d1 fix(app): keep React aligned with native renderer 2026-05-21 12:44:19 +07:00
paseo-ai[bot]
b5a78b7261 fix: update lockfile signatures and Nix hash [skip ci] 2026-05-21 04:26:52 +00:00
Mohamed Boudra
e630e205db chore(release): cut 0.1.79-beta.2 2026-05-21 11:23:34 +07:00
Mohamed Boudra
8865f41d58 fix(app): unblock android APK build by aligning native deps
The 0.1.79-beta.1 APK build failed on EAS because react-native-unistyles
3.2.4 (bumped in #1103) references JHybridObject::CxxPart, which only
exists in react-native-nitro-modules 0.35.x. We were still on 0.33.8 so
the Android NDK compile died with "no member named CxxPart". Web and
desktop builds didn't catch it because the unistyles C++ layer is
Android-only.

Bump nitro to 0.35.5 to match unistyles' expected API. While re-resolving
the tree, react-native 0.81.6 also tightens its react peer to ^19.1.4,
so bump react/react-dom alongside so npm install stays clean.

Verified with a local production-apk gradle build using EAS's exact
gradleCommand — assembleRelease completes and the APK lands.
2026-05-21 11:19:52 +07:00
Mohamed Boudra
b94527fe1f fix(app): show empty state in import sheet when no sessions match 2026-05-21 11:19:52 +07:00
Mohamed Boudra
dee8f485de Recover stale daemon connections with liveness probes (#1124)
Use top-level WebSocket ping/pong for client liveness so session RPC timeouts stay scoped to the operation that timed out. Back off inactive connection probes while a host already has a healthy active connection.
2026-05-21 11:43:22 +08:00
Mohamed Boudra
28415b3542 docs(release): make betas silent, add release-beta/stable skills 2026-05-21 10:20:21 +07:00
Mohamed Boudra
3dd305082e docs(alternatives): add hero mockup to alternative comparison pages 2026-05-21 10:12:04 +07:00
Mohamed Boudra
cae3deb6e7 fix(website): SEO canonicals, host redirects, sitemap, app noindex
Addresses the high-volume issues from the Ahrefs audit:
- Worker entrypoint 301s every request not on https://paseo.sh
  (www subdomain and http variants), killing the duplicate-page
  errors and the HTTP→HTTPS internal-link notices.
- pageMeta() emits <link rel="canonical"> and og:url per route;
  every head() call now passes its canonical path.
- /blog stops rewriting itself to /blog?drafts=false; the search
  param only appears when explicitly true.
- Sitemap includes /blog and each post.
- Trim homepage title under SERP truncation, rewrite /agents meta
  description under 160 chars, pad short descriptions on
  /privacy, /download, /cloud, /changelog, /docs.
- app.paseo.sh gets <meta robots=noindex,nofollow> and a
  Disallow-all robots.txt so the SPA shell stops polluting the
  crawl with H1-missing / no-OG / low-word-count warnings.
2026-05-21 10:07:41 +07:00
Mohamed Boudra
ea36f0879f docs(website): rewrite agent pages for SEO and desktop coverage
Every agent landing page now mentions both mobile and desktop, leads with
"open source", and uses a search-friendly hero title. Renames Pi to "Pi Agent"
and disambiguates other common-word names in titles.
2026-05-20 22:31:28 +07:00
Mohamed Boudra
3bca1a72e8 Create agents in worktrees with auto-archive (#1120)
* Add daemon worktree auto-archive spawning

* Refactor create agent lifecycle dispatch

* Stabilize Claude autonomous turn test
2026-05-20 22:41:15 +08:00
paseo-ai[bot]
68d88f0928 fix: update lockfile signatures and Nix hash [skip ci] 2026-05-20 14:39:26 +00:00
Mohamed Boudra
8a69d66f8e chore(release): cut 0.1.79-beta.1 2026-05-20 21:35:34 +07:00
Mohamed Boudra
c7872d968b docs(changelog): draft 0.1.79-beta.1 entry 2026-05-20 21:34:01 +07:00
Mohamed Boudra
8c4f5940d6 fix(server): launch Pi through the Windows-aware spawn helper (#1121) 2026-05-20 22:31:23 +08:00
paseo-ai[bot]
9b2b511abe fix: update lockfile signatures and Nix hash [skip ci] 2026-05-20 12:17:32 +00:00
Matteo Pietro Dazzi
e58ea31d6d chore: upgrade vitest (#1091)
* chore: upgrade vitest

* chore: sync lock
2026-05-20 12:14:13 +00:00
吴天一
c88f6fb2c2 docs: update built-in provider references (#1105) 2026-05-20 09:45:38 +00:00
a3a8527a1c Fix Dvorak paste shortcut handling
Fixes #1083
2026-05-20 17:34:49 +08:00
Mohamed Boudra
f1b3e25344 Merge branch 'main' of github.com:getpaseo/paseo 2026-05-20 16:24:58 +07:00
paseo-ai[bot]
18a1bdcf72 fix: update lockfile signatures and Nix hash [skip ci] 2026-05-20 08:57:53 +00:00
Mohamed Boudra
f933bf6b3a fix: stop leaking CSS rules from dynamic UI styles (#1084) (#1103)
* fix: stop leaking CSS rules from dynamic position styles

* Fix floating surfaces on web

* Stop diff controls growing Unistyles rules

* Use inline style escape hatch in diff pane

* Centralize dynamic style escape hatches

* Restore startup workspace navigation

* Upgrade Unistyles for web cleanup fix

* Update review style test for inline styles

* Use redirect for startup workspace restore

* Update startup redirect test
2026-05-20 16:55:00 +08:00
Mohamed Boudra
905a0985f9 Forward create agent env to provider launches (#1112) 2026-05-20 15:23:05 +08:00
Zexin Yuan
ca913728d9 Add publicUseTls option to NixOS module (#1106) 2026-05-20 07:20:13 +00:00
Mohamed Boudra
0d495a9625 Fix startup workspace restore (#1111) 2026-05-20 07:00:29 +00:00
Chris Banes
3786cf3569 Wait for Cursor ACP slash commands before listing. (#1099)
cursor-agent publishes commands asynchronously via available_commands_update, so listCommands() returned an empty cache when called right after session/new. Enable waitForInitialCommands only on CursorACPAgentClient with a 10s timeout; other ACP providers keep the immediate-return default.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 13:47:10 +08:00
Mohamed Boudra
8a6bdb2d01 Run daemon dev diagnostics on workers 2026-05-20 09:36:09 +07:00
paseo-ai[bot]
555d10f046 fix: update lockfile signatures and Nix hash [skip ci] 2026-05-19 16:02:09 +00:00
Mohamed Boudra
a1a5119bc5 Merge branch 'main' of github.com:getpaseo/paseo 2026-05-19 22:57:37 +07:00
Mohamed Boudra
4cf985c254 Run Pi agents through the installed Pi CLI (#1097)
* Run Pi through its RPC interface

Replace the embedded Pi runtime packages with a process-backed runtime that starts pi --mode rpc, keeps the runtime port thin, and leaves provider behavior in the Paseo session and mapper modules.

Add focused adapter and mapper tests plus real Pi E2E coverage for the installed binary path.

* Keep Pi provider code in one module

* Add Pi session imports

* Enable Paseo MCP tools in Pi agents

Route injected MCP servers through Pi's MCP adapter and make structured MCP results visible to model-only clients.

Normalize Pi MCP proxy calls so Paseo timelines show the underlying tool names.

* Fix Pi import symlink tests on Windows

Create directory links explicitly in symlink cwd fixtures so Windows server tests exercise the intended path-equivalence behavior.

* Use native realpath for import cwd matching

Resolve import cwd matchers with native realpath so Windows symlink and junction sessions compare against the same path shape used by persisted provider data.

* Fallback realpath for Windows import matching

Use plain realpath when native realpath cannot resolve a cwd so symlink-equivalent persisted sessions still match on Windows runners.

* Add daemon system prompt support for Pi

* Fix Windows realpath import matching

* Remove Pi RPC migration plan doc
2026-05-19 23:56:07 +08:00
Mohamed Boudra
0e1c590a3b Reduce workspace git refresh polling (#1102)
* Reduce workspace git refresh polling

* Keep workspace git snapshot reads passive

* Update workspace git refresh tests
2026-05-19 23:54:52 +08:00
Mohamed Boudra
b7a8567092 Restore the previous workspace on app start (#1101)
* Restore previous workspace on app start

* Guard workspace startup hydration

* Extract last workspace selection store
2026-05-19 23:53:57 +08:00
Mohamed Boudra
ad9b149bf7 Keep workspace editor target selection 2026-05-19 21:30:43 +07:00
Mohamed Boudra
306601a0c3 Add daemon-wide system prompts (#1100)
* Add daemon-wide system prompt setting

* Align settings textarea rendering

* Polish daemon settings layout

* Remove orchestrator prompt injection
2026-05-19 14:24:56 +00:00
Mohamed Boudra
da0e94fd18 Add DeepSeek TUI to the ACP catalog (#1096)
* Consolidate provider selection handling

* List DeepSeek TUI in docs and website
2026-05-19 22:20:09 +08:00
Mohamed Boudra
84fb7e9e06 Merge branch 'main' of github.com:getpaseo/paseo 2026-05-19 20:49:46 +07:00
Mohamed Boudra
5dd6b030f2 Streamline model selector provider data 2026-05-19 20:01:56 +07:00
Mohamed Boudra
bc7798af28 Allow ACP providers without model lists 2026-05-19 20:01:56 +07:00
Mohamed Boudra
f238cbc20c Tighten DeepSeek TUI catalog copy 2026-05-19 20:01:56 +07:00
Mohamed Boudra
41c5eea678 Add DeepSeek TUI to ACP catalog 2026-05-19 20:01:56 +07:00
Mohamed Boudra
352e8fb6eb Show catalog provider icons (#1098) 2026-05-19 20:54:33 +08:00
Mohamed Boudra
8d65f8bc96 Suggest daemon upgrade on unknown RPC errors 2026-05-19 19:47:53 +07:00
Mohamed Boudra
2c561536e4 Merge branch 'main' of github.com:getpaseo/paseo 2026-05-19 18:12:20 +07:00
Mohamed Boudra
3743df09e6 Add rename for workspaces, terminals, and agent tabs (#531)
* Add rename for workspaces, terminals, and agent tabs

Surfaces rename via the sidebar workspace kebab (git branch rename with
client-side slugify), the terminal tab context menu (stops OSC 2 auto-title
overrides), and the agent tab context menu (locks the title against the
metadata generator). A shared RenameModal wraps AdaptiveModalSheet and
replaces the inline rename on the host page.

Auto-title races are closed from both sides: AgentManager gains
setGeneratedTitleIfUnset with an atomic per-agent write queue, and
TerminalSession replaces lockedTitle with a titleMode discriminated union
so user rename flips to manual and disposes the OSC subscription.

New WebSocket messages are additive: rename_terminal_request/response and
checkout_rename_branch_request/response (branch rename uses the
CheckoutError family). A new @getpaseo/server/utils/branch-slug subpath
export shares slugify + validateBranchSlug between server and app.

* Tighten rename feature and restore lost rebase wiring

Unslop pass on the rename commit plus two rebase artifacts:

- session.ts: collapse handleRenameTerminalRequest to a respond helper
  with early returns; restore workspaceGitWatchTargets.set() in
  syncWorkspaceGitObserver (lost during rebase onto main, which broke
  onBranchChanged firing on branch rename).
- terminal.ts: remove DA1 query handler accidentally re-introduced by the
  rebase (main intentionally removed it); restore conditional
  onTitleChange registration under titleMode === "auto".
- rename-modal.tsx: drop unconfident optional-call on setNativeProps and
  the unknown-cast HTMLInputElement narrowing dance.
- sidebar-workspace-list.tsx: remove unused branch field from rename
  result; flatten validateRenameSlug into early returns.
- host-page.tsx: drop ?? "" fallback on a typed-string field.

* Fix typecheck after rebase: pass parsed config to getScriptConfigs

main refactored getScriptConfigs to take the parsed paseo.json config
instead of a repo path. spawnWorktreeScripts (added in the rename
feature) was still passing repoRoot. Read and parse the config first,
matching how spawnWorkspaceScript already does it.

* Resolve lint regressions and restore lost rebase fixes

Post-rebase cleanup: drop the await on syncWorkspaceGitObservers so
fetch_workspaces emits the response before any cold registration-
triggered git work fires (workspace.id is already on the descriptor —
no registry lookup needed). Restore the DA1 CSI handler that answers
\x1b[?62;4;22c on the daemon-side xterm so foreground apps like nvim
get a reply on stdin. Extract WorkspaceTabRenameModal/useWorkspaceTabRename
to drop WorkspaceScreenContent below the cyclomatic-complexity ceiling.
Switch session test internals to handleMessage so we exercise the public
dispatcher and avoid casting through any. Convert literal 'type' aliases
to interfaces and stop spawning callbacks inline so the codebase passes
the post-rebase oxlint rules.

* Fix typecheck after rebase: wire workspaceGitService and worker setTitle

Bootstrap was missing workspaceGitService when constructing
CreatePaseoWorktreeWorkflowDependencies after main's worktree workflow
refactor. Worker terminal manager needed setTitle on the session and
setTerminalTitle on the manager to satisfy the rename additions to
TerminalSession/TerminalManager interfaces.

* Format session.test.ts after rebase

* Remove stray auto-spawn of workspace scripts after bootstrap

The rebase brought in a spawnWorktreeScripts helper and a call inside
runWorktreeSetupInBackground that auto-started every configured workspace
script after worktree setup completed. main never auto-started scripts —
this regressed the workspace-setup-streaming Playwright test, which
expects the "web" script to be idle so the user can click Run.

Drops the helper, the call, and the workspaceGitService dep that only
existed to feed it.

* Fix checkout branch rename tests

* Fix sidebar checkout action store import

* Skip POSIX terminal tests on Windows

* Unslop the rename-entities feature

Six audit findings closed and ~450 net lines trimmed from the branch:

- Remove the duplicate "rename-branch" union member in
  GitMutationRefreshReason.
- Fold dispatchStashMessage back into handleSessionMessage; the split was a
  feature-first artifact of adding checkout_rename_branch_request, with no
  documented rationale.
- Drop the protected beforeGeneratedTitleIfUnsetWrite test seam from
  AgentStorage; rewrite the race test to exercise real Promise.all
  concurrency against the existing per-agent write queue.
- Reshape useWorkspaceTabRename so the hook returns state and handlers
  only; promote WorkspaceTabRenameModal to an exported component the
  consumer renders directly.
- Rename RenameModal to AdaptiveRenameModal so it reads as a generic
  primitive next to AdaptiveModalSheet, and update host-page,
  sidebar-workspace-list, and the workspace tab rename hook to import it
  by its new name.
- Trim duplicate matrix coverage and Zod self-tests across
  rename-modal.test.tsx, terminal.test.ts, session.test.ts,
  agent-storage.test.ts, messages.rename-entities.test.ts and the three
  rename e2e specs; introduce packages/app/e2e/helpers/rename.ts to share
  setup across the e2e specs without merging coverage.

Behavior of the rename feature is unchanged; targeted vitest, branch-wide
typecheck, and lint all green.

* Restore agentMetadataMocks dropped during rebase

5e6aeb2d removed the agentMetadataMocks hoisted definition and its
vi.mock wiring when it deleted the import describe block. The block
was kept (it belongs to main's import feature) but the mock support
was left behind.

* Fix terminal-manager tests using hardcoded /tmp on Windows

setTerminalTitle tests used cwd: "/tmp" which is not a valid directory
on Windows, causing node-pty error code 267 (ERROR_DIRECTORY). Use
realpathSync(tmpdir()) like the rest of the test file.

* Fix typecheck and lint after rebase onto main

AdaptiveModalSheet moved from a `title` string prop to a structured
`header: SheetHeader`; update AdaptiveRenameModal to memoize and pass
a SheetHeader. invalidateCheckoutGitQueriesForClient moved from
git/actions-store to git/query-keys. useWorkspaceTerminals now owns
the terminal query, so workspace-screen pulls queryKey from the hook
and re-declares queryClient via useQueryClient.

* Update agent metadata test mock to match setGeneratedTitleIfUnset rename

The branch renamed AgentManager.setTitle to setGeneratedTitleIfUnset
for the rename feature; the generateTitlePromptWithConfig helper still
mocked the old method, so eight prompt-byte tests crashed with
"setGeneratedTitleIfUnset is not a function" on both ubuntu and
windows server-tests jobs. Sister mocks in the same file were already
on the new name.

* Fix rename modal showing empty input by using controlled TextInput

AdaptiveTextInput (introduced on main by 29ce6653f) is intentionally
uncontrolled: it drops the `value` prop and seeds the native input
once via `initialValue`. AdaptiveRenameModal still passed `value=
draft` from the pre-rebase shape, so every rename modal opened with
an empty textbox and a disabled Save button. Three playwright e2e
specs (settings-host-page, sidebar-workspace-rename, workspace-agent-
tab-rename) failed for this reason.

Switch the rename modal to a plain controlled TextInput so the input
seeds with the current label and the slug transform (used by sidebar
workspace rename) reflects live in the textbox as the user types.
The unit test's old AdaptiveTextInput mock is replaced with a
react-native mock providing a controlled TextInput shim that captures
the same onChangeText/onSubmitEditing handlers.

* Revert "Fix rename modal showing empty input by using controlled TextInput"

This reverts commit 6283deae842b8a5fed63cb8bf06c78029f98386e.

* Seed rename modal input via AdaptiveTextInput's initialValue + resetKey

AdaptiveTextInput is intentionally uncontrolled — it drops `value` and
seeds the native input once with `initialValue`. The rename modal was
passing `value={draft}` from the pre-rebase shape, so every rename
modal opened with an empty textbox (three playwright specs failing).

Pass `initialValue={draft}` and bump `resetKey` only when the
transform rewrote what the user typed. That seeds the native input
with the current label on open, lets the user keep typing inside
text without cursor jumps (no remount when transform is a no-op),
and remounts the native input with the slug when the transform
diverges so live slugification keeps working in sidebar workspace
rename. Keeping AdaptiveTextInput preserves the BottomSheetTextInput
swap on mobile so the keyboard stays above the sheet — using a plain
TextInput would break that.

The unit test mock is updated to read `initialValue`/`resetKey` so it
mirrors production behavior instead of pretending the input is
controlled.

* Slugify branch rename at submit only, drop live transform

The rename modal's transform prop remounted the native input every
time the slug diverged from what the user typed, which lost focus
mid-edit on every uppercase letter or space in the sidebar workspace
rename. Live-rewriting what the user typed was also surprising — the
expectation is that you type a name, the daemon stores a slug.

Drop the transform prop from AdaptiveRenameModal entirely. Sidebar
workspace rename now slugifies once in handleSubmitRename before
calling renameBranch, and validateRenameSlug runs validateBranchSlug
against the slugified value so the user sees inline errors. The
playwright spec drops the live-slug assertion; it still verifies the
post-submit branch on disk and the rename request payload.

* Rename new rename RPCs to dotted convention

docs/rpc-namespacing.md says new RPCs use dotted names with the
direction as the final segment, and explicitly bans new flat snake_
case names. This PR introduced two flat ones; rename them in place
before the protocol ships:

- checkout_rename_branch_request → checkout.rename_branch.request
- checkout_rename_branch_response → checkout.rename_branch.response
- rename_terminal_request → terminal.rename.request
- rename_terminal_response → terminal.rename.response

Touched: the Zod literals in messages.ts, the discriminated union
entries, the session dispatcher and its tests, the daemon client
wrappers and their tests, the terminal session controller, and the
message-parsing rename-entities test. No callers exist outside the
server package — app and CLI go through the daemon-client wrappers,
which now emit the dotted names.

* Match rename modal Save button and input focus to app conventions

Save uses Button variant=default so it gets the same accent
background + white text as every other primary action in the app
(open-project, settings host save, pair-link confirm, etc).

The input previously fell back to the browser's blue focus outline
on web because the rename modal never set outlineStyle: none — every
other AdaptiveTextInput call site that wants accent feedback already
does this. Kill the browser outline, track focus state, and switch
borderColor to accent while focused.

* Theme the focus-visible outline color via AdaptiveTextInput

public/index.html paints a 2px :focus-visible outline on every web
element with a hard-coded #20744a (Paseo green). On the Claude theme,
the rename modal's input got that green ring instead of the theme's
brown accent — visible mismatch against every other accent-colored
control (primary buttons, etc).

Add an outlineColor entry to AdaptiveTextInput's stylesheet sourced
from theme.colors.accent. Unistyles' Babel plugin tracks the read and
updates the native ShadowTree on theme switch — no React re-render,
no useUnistyles() call (forbidden on this hot path per docs/unistyles.md).
The inline outline-color on the rendered DOM input overrides the
selector-level color from index.html; outline-width/style/offset
still come from the global rule.

Consumer style merges in after, so existing callers that pass
outlineColor (message-input, review/surface, question-form-card)
still win.

Also drop the half-baked isFocused/focusedBorderStyle local state
I added to rename-modal earlier — the AdaptiveTextInput fix removes
the need for it.

* Drop useUnistyles() from rename modal

The rename modal only read theme.colors.foregroundMuted to pass as
the TextInput's placeholderTextColor prop. The rest already went
through StyleSheet.create((theme) => ...). Per docs/unistyles.md the
hook is forbidden when an alternative exists — and this is exactly
the alternative the rest of the codebase uses (project-settings-screen,
add-host-modal, pair-link-modal, command-center): put the muted color
in a tiny StyleSheet entry and read .color off it for the prop.

* Default placeholderTextColor in AdaptiveTextInput

placeholderTextColor was being duplicated at every AdaptiveTextInput
call site (add-host-modal, command-center, pair-link-modal,
project-picker-modal, provider-diagnostic-sheet, combobox, the
sheet's own search inputs, etc.) — all passing the same
theme.colors.foregroundMuted. The shared input should own this.

Move it into AdaptiveTextInput as a default sourced from the same
StyleSheet.create(theme => ...) block as the accent outline. Consumers
still override via the prop if they need a different color. Strip
the redundant placeholderTextColor and local placeholderColor style
entry from rename-modal; other call sites can be cleaned up in a
follow-up.

* Update rename e2e specs to use dotted RPC type strings

Earlier commit (5d5624943) renamed the new rename RPCs to the dotted
convention but missed the two e2e specs that capture the WebSocket
frames by type. captureWsSessionFrames matched the old flat names, so
renameRequests / renameFrames stayed empty even though the rename
went through end to end — the sidebar updated and the branch was
renamed on disk. The toBeGreaterThan(0) assertion fired and the test
failed in playwright CI.
2026-05-19 18:05:45 +08:00
Mohamed Boudra
0a2307d199 Avoid duplicate Claude result text (#1095) 2026-05-19 17:43:26 +08:00
Mohamed Boudra
0f6641c8c0 Show resolved file paths in agent file-link tooltips (#1088)
* fix: show resolved file paths in agent file-link tooltips

Bare filenames in agent messages now resolve to their full workspace
path on hover. Also raises the daemon's directory-suggestion scan
depth so files in deeper package layouts are reachable.

* fix(app): keep file-link wrapper stable to avoid layout shift on resolve

* fix(app): resolve file links on hover instead of at render time

useQuery was firing the daemon RPC for every ambiguous file reference
the moment a message rendered, fanning out a wave of requests on chat
scroll. Switch to enabled: false + prefetchQuery on hover so RPCs are
driven by user intent. Sync-resolvable refs (directFile, external)
still seed via initialData and render with no RPC.

Also memo on source primitives rather than identity so identical-
content sources constructed inline upstream don't bust the memo every
render.

* Redesign assistant file link resolution

* Stabilize assistant file link handlers
2026-05-19 15:32:48 +08:00
Mohamed Boudra
fdecd75f94 Add Claude project-dir resolver matching SDK encoding
Claude's Agent SDK encodes a cwd into ~/.claude/projects/<dir> by
realpath + NFC normalizing the path, replacing every non-alphanumeric
character with "-", and (when the encoded result exceeds 200 chars)
appending a base-36 hash. paseo's existing sanitizer only replaces
five characters and skips realpath, so it misses the SDK's directory
whenever the cwd has a symlink (macOS /var → /private/var), spaces,
parens, NFD unicode, or exceeds the length cap.

This adds the resolver alongside the existing logic; wiring it into
agent.ts to replace resolveHistoryPath is the next step. Tests assert
parity by writing a session file at our computed path and asking the
SDK's getSessionInfo({ dir }) to find it — if both encoders agree,
the SDK finds it.
2026-05-19 13:42:24 +07:00
paseo-ai[bot]
b41cb72da0 fix: update lockfile signatures and Nix hash [skip ci] 2026-05-19 06:00:14 +00:00
Yurui Zhou
cb96485035 feat(server): upgrade embedded Pi SDK (#1087)
* feat(server): upgrade embedded Pi SDK

* fix: sync package-lock.json with package.json

The Pi SDK upgrade commit (63e18a9d) regenerated package-lock.json in a
way that dropped packages/website's react@19.2.6, react-dom@19.2.6, and
scheduler@0.27.0 (website depends on react ^19.1.4, which resolves to
19.2.6). This broke `npm ci` in CI, failing all 14 checks at the install
step with EUSAGE "package.json and package-lock.json not in sync".

Regenerated with `npm install`.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 13:56:32 +08:00
ezra
ce822f989f Fix Codex Microsoft Store binary detection on Windows (#1020)
Fixes #1013
2026-05-19 05:12:55 +00:00
Bolun Zhang
e38d0e0fa9 feat(mcp): consolidate provider settings tools (#1011)
Closes #984
2026-05-19 12:56:03 +08:00
João Sousa Andrade
bc32b16b02 Reject relay re-handshake key changes (#1037)
Closes #366
Closes #368
2026-05-19 12:38:55 +08:00
Mohamed Boudra
b2cdbdaed8 Hide import pill once the draft is submitting 2026-05-19 10:26:13 +07:00
Mingyang Sun
9ef7230417 Add Kiro CLI to ACP provider catalog
Adds Kiro CLI as an opt-in ACP provider catalog entry, plus a generic ACP extension-notification sink so unknown notifications no longer fail JSON-RPC dispatch.
2026-05-19 03:23:57 +00:00
Zexin Yuan
383b380d8a fix(cli): query daemon for status and pairing offer over RPC
Closes #1081
2026-05-19 03:15:52 +00:00
Mohamed Boudra
339ca2fc83 Make zinc accent monochrome and respect accentForeground 2026-05-19 10:01:39 +07:00
xy-plus
8ff63a6d71 fix(server): keep Codex sub-agent running on transient child error state
Fixes #1071
2026-05-19 10:57:55 +08:00
Yurui Zhou
aaabadb04b fix: prevent macOS desktop unlock freeze after display sleep (#745)
* Restart the GPU process to recover from macOS compositor freezes

macOS display sleep can leave Chromium's GPU-process display link stuck
on a stale display, so the compositor stops producing frames and the
window looks frozen — unresponsive to clicks and keys — even though the
renderer stays alive.

setupDarwinCompositorWatchdog polls the renderer for frame production
and, on a sustained stall while the window is visible and unlocked,
restarts the GPU process so Chromium rebuilds the display link. This
replaces setupDarwinPaintRefresh, whose invalidate/resize nudges did
not address the dead display link.

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

* Give compositor watchdog a module home

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-05-19 10:41:55 +08:00
Mohamed Boudra
501dcf373b Hide voice mode while agents run 2026-05-19 08:18:56 +07:00
Samatar
5a1c7f266c fix(server): render non-ASCII filenames in git output
Fixes #436
2026-05-19 01:16:33 +00:00
Samatar
9bc6210823 fix(terminal): send SIGINT for hardware Ctrl+C on iPad
Fixes #1049
2026-05-19 09:02:48 +08:00
paseo-ai[bot]
8aa8f7a0cc fix: update lockfile signatures and Nix hash [skip ci] 2026-05-18 11:04:02 +00:00
Mohamed Boudra
63fe5dd0df chore(release): cut 0.1.78 2026-05-18 17:59:57 +07:00
Mohamed Boudra
1b9861846c Improve 0.1.78 changelog and add user-facing examples to release docs 2026-05-18 17:58:49 +07:00
Mohamed Boudra
68bce623c8 Route OpenCode live tests through OpenRouter
Big Pickle's models.dev metadata changed upstream to route via the
Anthropic SDK against the Zen OpenAI-compatible endpoint, which the
endpoint rejects. Swap the live-turn smoke tests to
openrouter/~openai/gpt-mini-latest and gate them on OPENROUTER_API_KEY
so fork PRs skip cleanly when the secret is absent. Also unpin
opencode-ai so CI tracks the latest CLI.
2026-05-18 17:16:57 +07:00
Mohamed Boudra
2072265b98 Stabilize OpenCode provider unit tests 2026-05-18 16:08:46 +07:00
Mohamed Boudra
f7ef0e0b84 Add 0.1.78 changelog 2026-05-18 16:08:21 +07:00
Mohamed Boudra
0419346d6a Pin OpenCode CLI in CI 2026-05-18 16:01:30 +07:00
paseo-ai[bot]
2866a12984 fix: update lockfile signatures and Nix hash [skip ci] 2026-05-18 08:32:53 +00:00
Mohamed Boudra
120f5d94a9 Fix compact web sheet swipe crashes 2026-05-18 15:29:01 +07:00
Mohamed Boudra
29ce6653fd Improve mobile selectors and sheet inputs
Keep sheet text inputs native-owned to avoid React Native controlled input flicker during fast typing. Add reset keys for fields that need programmatic clears, and move model, mode, and thinking pickers onto shared combobox rows with fuzzy ranked search and native virtualization.
2026-05-18 14:56:17 +07:00
Mohamed Boudra
028839b3cb Add comparison pages under /docs/alternatives
Group the docs sidebar by directory so the new pages cluster
under a heading, add a hamburger menu for the mobile docs nav,
and let the docs main column shrink so wide code blocks and
tables scroll inside instead of pushing the page wide.
2026-05-18 12:59:04 +07:00
Mohamed Boudra
82319f5805 Keep agent panes mounted and focused 2026-05-18 11:24:56 +07:00
Mohamed Boudra
69fc6fe754 Document the positive submission-verification path (eas build:view Logs URL) 2026-05-18 10:16:42 +07:00
Mohamed Boudra
1b2a28be47 Drop eas submit:list references from release/android docs 2026-05-18 10:15:10 +07:00
Mohamed Boudra
665d9cedb5 Drop bogus eas submit:list from release docs and heartbeat checks 2026-05-18 10:14:29 +07:00
Mohamed Boudra
0d0012959a Lock babysit pattern: heartbeat to self, every 15m maxRuns 8 2026-05-18 09:59:00 +07:00
Mohamed Boudra
77b92b58f7 Drop trailing periods from changelog and fix mobile release docs
- Strip end-of-bullet periods from CHANGELOG.md (whole file)
- Tighten 0.1.77 TLS bullet to drop LAN/public explanation
- Release docs: drop release-mobile.yml references (workflow does not exist),
  add EAS mobile-build babysitting section using the EAS CLI, clarify that
  releases are always patch, clarify that "stable" means stable
- Changelog style guide: no trailing periods, one sentence per bullet, lead
  with capability not mechanism
2026-05-18 09:33:46 +07:00
paseo-ai[bot]
d4ebf1815c fix: update lockfile signatures and Nix hash [skip ci] 2026-05-18 02:26:22 +00:00
Mohamed Boudra
2f638fe6eb chore(release): cut 0.1.77 2026-05-18 09:22:18 +07:00
Mohamed Boudra
b8154aa72b Draft changelog for 0.1.77 2026-05-18 09:21:32 +07:00
Mohamed Boudra
a2d8ce07d6 Stop OpenCode probes from creating empty sessions (#1079)
* Avoid empty OpenCode sessions during metadata probes

* Stabilize supervised restart regression test
2026-05-18 09:58:34 +08:00
Mohamed Boudra
721f1ee8d3 Fix inline code file matching 2026-05-17 23:40:31 +07:00
Mohamed Boudra
b55aa042d4 Return to parent tabs on close 2026-05-17 23:06:39 +07:00
Mohamed Boudra
1f7fc232b3 Limit tool call coalescing to prepend boundary 2026-05-17 22:24:59 +07:00
Zexin Yuan
338a41991d feat: add independent TLS control for relay public endpoint (#1045)
* feat: add independent TLS control for relay public endpoint

Add PASEO_RELAY_PUBLIC_USE_TLS env var and publicUseTls persisted
config to separately control TLS for the client→relay pairing offer,
falling back to relayUseTls when unset. The daemon→relay connection
continues to use PASEO_RELAY_USE_TLS alone.

Enables self-hosting behind a TLS-terminating reverse proxy where
the internal path is plain ws:// but public clients need wss://.

* fix: use public relay TLS in daemon status

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-05-17 23:00:42 +08:00
Mohamed Boudra
ee44b536f5 Support line ranges in assistant file links (#1067) 2026-05-17 22:55:46 +08:00
Mohamed Boudra
efc9c2d345 Preserve tool call idempotency 2026-05-17 21:09:29 +07:00
Mohamed Boudra
c4e45af565 Coalesce prepended tool call history 2026-05-17 20:53:03 +07:00
Mohamed Boudra
e27734b218 Use surface3 for user message bubble
Drops the accentSubtle theme token in favor of an existing surface layer.
surface3 gives a clear step up from surface0 (chat bg) without the tinted
look the accent fill produced.
2026-05-17 19:41:49 +07:00
accx
686a25fb65 Fix Windows import session path matching (#1012) 2026-05-17 20:32:28 +08:00
Mohamed Boudra
0e3a78b308 Stabilize mobile sidebar close test (#1065) 2026-05-17 20:32:06 +08:00
ezra
edb0ba888a Make terminal scrollback configurable (#1021)
* Make terminal scrollback configurable

* Fix terminal scrollback lint violation

* Fix terminal runtime test environment split

* Fix terminal scrollback updates

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-05-17 19:58:35 +08:00
Mohamed Boudra
00e7ac9ec8 Fix OpenCode custom command hangs (#1063)
* Fix OpenCode slash command turns

* ci: install opencode in server-tests so real OpenCode e2e runs

Server tests previously only installed the Claude Code CLI, so every
opencode-*.real.e2e.test.ts self-skipped on CI. The custom-command and
turn-lifecycle real tests cover the OpenCode session SSE rewrite, so
add opencode-ai globally and let them run. Big Pickle is free so no
provider secrets needed.

* Fix OpenCode unit tests exposed by enabling opencode in CI

server-tests previously skipped every OpenCodeAgentClient test because
opencode-ai was not installed. Now that CI runs them, four real issues
surface:

- Drop unused mkdirSync import (was used on a code path 1812b1489
  refactored away on main; the rebase merged imports without rechecking
  usage). Fixes the lint job.
- Fix slash-command-timeout test passing four ctor args instead of three,
  which silently dropped the TestOpenCodeRuntime and ran against the real
  opencode runtime.
- Relax the listModels contextWindowMaxTokens assertion. Some providers
  routed through OpenCode (OpenAI in particular) don't expose a numeric
  context window; assert the type only when the field is present.
- Swallow late notifySubscribers calls once close() flips the closed
  flag and clear subscribers up front. The session-lifetime SSE stream
  introduced by the prior commit means a session.error arriving after
  the owning test has moved on can rejectCompletion on a deferred no
  one is awaiting, surfacing as an unhandled rejection in a downstream
  test file.
2026-05-17 19:51:20 +08:00
c4605
b3c272f720 Fix native diff row expansion (#940)
* Fix structured diff parsing for no-prefix paths

Git can emit diff --git path path when diff.noprefix is enabled. The structured diff parser only handled the default a/path b/path form, so checkout diff assembly could lose hunks for affected files and render expanded rows empty.

Normalize optional a/ and b/ prefixes in both app and server parsers, and cover the no-prefix header form with regression tests.

* Fix no-prefix diff path parsing

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-05-17 18:58:10 +08:00
Mohamed Boudra
bb5c3dae00 Reduce user bubble desaturation 2026-05-17 15:39:34 +07:00
Mohamed Boudra
ed7944cf9d Distinguish user message bubbles with accentSubtle fill
User bubbles previously used surface2, which blended into the surrounding
chat surface. Add an `accentSubtle` theme token (accent darkened, slightly
desaturated, low alpha) and use it for the bubble fill so user messages
read as distinct without shouting.

Fixes #986
2026-05-17 15:36:12 +07:00
Bolun Zhang
91d257e103 fix(app): widen host switcher popover (#981)
Co-authored-by: zbl <zbl@zbl-M4Pro.local>
2026-05-17 08:27:06 +00:00
Myriad-Dreamin
e379707a68 Show diff file paths in a tooltip (#1061)
* fix: add file path titles to diff headers

* Show diff file paths in a tooltip

* Fix workspace layout hydration

* Revert "Fix workspace layout hydration"

This reverts commit 05c89909b7.
2026-05-17 16:02:29 +08:00
Mohamed Boudra
f6afe0d864 feat(app): syntax highlighting and copy button for chat code blocks (#1062)
Adds Lezer-based highlighting to fenced code blocks in assistant messages,
with a hover/tap copy button. Colors move into the theme as
`theme.colors.syntax`, so the chat blocks, file preview, and diff viewer
share one StyleSheet path — token color updates flow through the Unistyles
native ShadowRegistry with no React re-renders on the chat hot path.
2026-05-17 16:01:08 +08:00
ezra
952b58c0eb Fix Windows Android app scripts (#1058)
* Fix Windows Android app scripts

* ci: skip onnxruntime CUDA download
2026-05-17 14:06:42 +08:00
ezra
9324a5b67a feat(app): show mode icons in agent status menu (#1059) 2026-05-17 13:56:14 +08:00
Samuel K
ec646db844 fix(mcp): add missing schedule tools (update, logs, run-once) (#1032)
* fix(mcp): add missing schedule tools (update, logs, run-once)

Add three schedule MCP tools to close the parity gap between
CLI (`paseo schedule update/logs/run-once`) and MCP surface.

The WebSocket RPC handlers and ScheduleService methods already
exist (added in PR #694), but the MCP tools were never registered.

Tools added:
- update_schedule: update interval, prompt, name, maxRuns, etc.
- schedule_logs: retrieve run history for a schedule
- run_once_schedule: trigger immediate one-time execution

Includes 8 unit tests covering all three tools.

Fixes #1031

* Fix schedule MCP update handling

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-05-16 22:45:22 +08:00
Mohamed Boudra
1812b14898 Fix OpenCode session imports (#1055)
* Fix OpenCode session imports

* Update OpenCode session tests after import cleanup
2026-05-16 22:08:26 +08:00
Mohamed Boudra
1c38ffe0a9 Fix composer scrollbar resize loop (#1050) 2026-05-16 19:31:24 +08:00
Mohamed Boudra
f4a4e0c25c fix(ios-release): submit each iOS build to its own App Store version
The submit_review fastlane lane identified the just-uploaded build by
CFBundleVersion alone. In this project the production iOS profile resets
the build number per marketing version, so every release sits at build 2
and Spaceship::ConnectAPI::Build.all returns the whole TestFlight history.
The .find call picked an arbitrary match, which is how 0.1.76's binary got
attached to the 0.1.75 App Store version slot and submitted for review
under the wrong label.

The release workflow now forwards app_version and app_build_version from
the build_ios outputs into the fastlane step as env vars, and the Fastfile
filters builds by marketing version plus build number to pin the exact
binary. The standalone resubmit workflow takes the same identifiers as
optional workflow_dispatch inputs; with both unset it falls back to "most
recently uploaded iOS build" so manual reruns still work.

Pinning fastlane in the Gemfile to lock the Spaceship behavior this lane
depends on.
2026-05-16 16:46:10 +07:00
Mohamed Boudra
a7dd0da2aa app: use SyncedLoader for chat thinking indicator 2026-05-16 16:46:10 +07:00
Mohamed Boudra
618b4a30b1 Refactor chat file-link opens to a single disposition seam (#1038)
* Open chat file links in a side pane on cmd-click

* Add hover tooltips to file links showing cmd-click hint

* Collapse file-link open path to a single disposition seam

Replace the parallel "open in side" callback that was threaded
alongside the normal open callback at every layer between the chat
click handler and the workspace router. One callback now carries a
`OpenFileDisposition` ("main" | "side") so the modifier-key decision
lives at the leaf and the routing decision lives at the workspace
screen, with no branching in between.

* Give assistant file links a home
2026-05-16 17:27:20 +08:00
Mohamed Boudra
4b02daed8a Add slash commands for ending and restarting agents (#1034)
* Add slash commands for ending agents

* Stabilize slash command e2e submit

* Unslop client slash command draft setup

* Fix slash commands while agent is running

* Fix slash command submit race

* Reshape client slash command execution

* Fix slash command tab cleanup
2026-05-16 17:03:59 +08:00
Mohamed Boudra
667f441cc0 Fix mobile web gestures, DnD activation, iOS zoom, and Enter behavior (#1048)
* fix(app): require long-press for sidebar dnd on mobile

* fix(app): right-edge open gesture for explorer sidebar

* fix(app): left-edge open gesture for workspace sidebar

* fix(app): allow gesture children to scroll on web

* fix(app): lock viewport zoom on compact web

* fix(app): don't submit on enter in compact web composer

---------

Co-authored-by: jon <nikuscs@gmail.com>
2026-05-16 14:20:24 +08:00
Matan Bendix Shenhav
5762055213 nix: include home-manager profile paths when inheriting user PATH (#1040)
`inheritUserEnvironment` set the daemon's PATH to NixOS user/system
profiles only, missing `~/.nix-profile/bin` and
`~/.local/state/nix/profile/bin` where home-manager installs per-user
CLIs. Result: agents like claude/opencode installed via home-manager
were invisible to the daemon and provider checks failed with
"Provider 'X' is not available. Please ensure the CLI is installed."

Prepend both home-manager profile dirs to PATH when running as a real
user (cfg.user != "paseo"), and update the option doc accordingly.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 13:13:57 +08:00
mehmet turac
675817f0b2 fix(cli): mention remote daemon host on ls connect failure (#1043) 2026-05-16 05:12:49 +00:00
nikuscs
35c582913d fix (app): mobile sidebar web interactions (#900)
* fix mobile sidebar web interactions

* fix(app): restore button role on sidebar project row

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-05-15 13:38:43 +00:00
Matan Bendix Shenhav
e309d82b41 nix: shrink daemon install with @vercel/nft tracing (#966)
* nix: share npmDeps FOD between paseo and paseo-desktop

The daemon and desktop derivations share package-lock.json, so their
npmDeps FODs contain byte-identical content. But Nix names the FOD
store path with the consuming pname prefix, so paseo-<v>-npm-deps and
paseo-desktop-<v>-npm-deps resolve to different store paths despite
identical bytes. Building both runs prefetch-npm-deps twice in
parallel; on a clean store this downloads the entire registry
(~1-2 GB of tarballs) twice over the wire.

Wire paseo through as a callPackage arg of the desktop drv and
inherit (paseo) npmDeps. One FOD, one fetch, one store path.

Override path becomes paseo.override { npmDepsHash = "..."; } — the
desktop drv picks up the overridden npmDeps transitively. Downstream
flakes that previously did paseo-desktop.override { npmDepsHash }
need to either drop the desktop-side override (recommended) or pass
the overridden daemon through as paseo-desktop.override { paseo }.

* nix: trace daemon runtime closure with @vercel/nft

The daemon Nix installPhase used to `cp -a node_modules $out/lib/paseo/`,
shipping every package in the hoisted root — Expo, React Native, Metro,
Electron, ML stacks, ~1700 third-party deps the daemon never `require()`s
at runtime. Result: ~2.6 GB store path for a daemon whose actual runtime
closure is a tiny fraction of that.

Replace it with static module-graph tracing via @vercel/nft (the same
library Vercel and Next.js use for serverless bundling). The new
installPhase runs `node scripts/trace-daemon.mjs`, which:

1. Calls nodeFileTrace on three entry points — cli/dist/index.js,
   server/dist/scripts/supervisor-entrypoint.js, and the forked
   server/dist/server/terminal/terminal-worker-process.js. The worker
   needs to be a separate entry because nft does not follow fork()
   process boundaries.
2. Unions the trace result with an explicit list of non-JS runtime
   inputs nft does not detect: shell-integration assets read via
   readFileSync, the Silero VAD ONNX model loaded by the sherpa
   provider, .env.example, the CLI shebang script, and node-pty's
   compiled prebuilt binary for the host platform.
3. Ignores cross-platform native variants we explicitly do not ship
   (sherpa-onnx-*, @mariozechner/clipboard-*) and node-fetch's
   optional `encoding` peer.

installPhase consumes the file list, copies each entry into
$out/lib/paseo/ preserving its repo-relative path, and wraps two bin
entries (paseo, paseo-server) via makeWrapper.

Verified end-to-end:
- daemon $out drops from ~2.6 GB to ~131 MB (≈95% reduction)
- paseo --version, paseo --help work from the new $out
- paseo-server boots, HTTP server reaches "Server listening" within
  ~40 ms, no missing-module errors in the bootstrap log
- speech features degrade gracefully when sherpa-onnx-* / model files
  are absent (the documented behaviour for the Nix build, unchanged)

@vercel/nft added to root devDependencies (1.5.0); used only at
build time by the Nix derivation.

* fix(nix): gitignore result

* fix(nix): refresh npm deps hash post-rebase

The conflict resolution during rebase kept the trace-daemon hash, but
the merged package-lock.json combines upstream's lockfile updates with
the @vercel/nft addition, so the hash needed to be recomputed.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 20:08:06 +08:00
Mohamed Boudra
5ea68dcdfa docs: refine changelog entries for 0.1.76 2026-05-15 18:43:50 +07:00
paseo-ai[bot]
01eb1ad512 fix: update lockfile signatures and Nix hash [skip ci] 2026-05-15 11:11:58 +00:00
Mohamed Boudra
39094078a9 chore(release): cut 0.1.76 2026-05-15 18:07:13 +07:00
Mohamed Boudra
160d861534 Fix agent turn timing persistence 2026-05-15 17:38:28 +07:00
Mohamed Boudra
6cbe4e6a14 Draft changelog for 0.1.76 2026-05-15 15:52:48 +07:00
Mohamed Boudra
0d05ff7efd Resolve inline code file links 2026-05-15 15:50:35 +07:00
Mohamed Boudra
dbe24b609d Fix OpenCode turn timing 2026-05-15 15:22:57 +07:00
Mohamed Boudra
bb6403e426 Stabilize agent turn footer layout 2026-05-15 15:19:00 +07:00
Mohamed Boudra
9bf36cdb56 Handle unborn repos in changes diff 2026-05-15 15:08:41 +07:00
Mohamed Boudra
1f79c4d039 Bump default desktop rollout to 36h 2026-05-15 14:47:42 +07:00
paseo-ai[bot]
5a74970d4a fix: update lockfile signatures and Nix hash [skip ci] 2026-05-15 07:11:28 +00:00
Mohamed Boudra
eed61d338c Tighten markdown list spacing around prose
Lists previously had a static marginVertical that stacked with
paragraph.marginBottom, producing 20px gaps and oversized whitespace
around paragraphs sandwiched between lists.

Spacing is now context-aware via the render rule: list -> list keeps
the normal paragraph gap (back-to-back lists read as continuous
content) while p <-> list transitions get a slightly larger gap (lists
read as their own section). Static margins can't satisfy both because
RN doesn't collapse margins, so the bullet_list / ordered_list rule
inspects the next sibling type and picks marginBottom accordingly.
2026-05-15 14:03:14 +07:00
Mohamed Boudra
2f13c83c0e Sync package-lock.json
Also exclude package-lock.json from the format pre-commit hook glob —
oxfmt internally ignores lockfiles but exits with an error when given
no remaining targets, breaking lockfile-only commits.
2026-05-15 13:35:41 +07:00
Mohamed Boudra
12e24f9620 Show built-in Claude slash command output
Claude CLI's built-in slash commands (/voice, /usage, "Unknown command:
...") run client-side with no model turn — they arrive as a result with
output_tokens: 0 and the user-visible text carried in result.result, but
no preceding assistant_message in the stream. Surface that text as an
assistant_message so the turn doesn't end silently.

Also unify Codex skill-command text blocks to always carry the
"$skill-name" prefix in the text payload, matching the no-args path
which already did.
2026-05-15 13:34:07 +07:00
Mohamed Boudra
37bb168204 Polish agent stream spacing, alignment, and metadata
- Centralize horizontal alignment: StreamItemWrapper / contentWrapper own
  the chat rail (paddingHorizontal: 8). Stream items stop setting their
  own outer horizontal padding. Copy icons get negative margins so the
  visible icon sits flush with the rail edges.

- Unify the inline working indicator and assistant turn footer into a
  shared slot (minHeight: 24, paddingBottom: 24). No layout shift on
  streaming -> idle, and both gain breathing room above the composer
  and the next user bubble.

- Equalize the gap on both sides of an assistant message adjacent to a
  tool call (20px each). Drop assistantMessageStylesheet.containerSpacing,
  which was a redundant marginBottom that only fired in one direction
  and duplicated getGapBetween's responsibility.

- Scale up the chevron via transform (size stays 12, viewBox padding no
  longer dominates), iconBadge dimensions unchanged. Tool icon and
  chevron now read as similar visual weight.

- Shared STREAM_METADATA_FONT_SIZE (13) for user message timestamp,
  assistant footer duration/timestamp, and the live elapsed timer.

- formatDuration: one rule for static and live displays - 0-10s as
  X.Xs, 10-60s as Xs, minutes/hours integers only. Drops the `mode`
  parameter.

- formatMessageTimestamp: explicitly carry the resolved hourCycle so
  the runtime applies an OS-level 24h preference when the platform
  exposes it (e.g. AppleICUForce24HourTime on macOS).
2026-05-15 13:30:57 +07:00
Mohamed Boudra
57db997028 docs: lock down hover pattern, fix user message bubble flicker
The user message bubble had hover on a `Pressable` wrapping the bubble,
with the timestamp and copy button as siblings outside it — moving the
cursor onto them lost hover. Wrapping both in a single Pressable then
flickered, because `TurnCopyButton` is itself a Pressable and nested
Pressables fight over hover state.

Rewrite `UserMessage` to match the workspace row pattern: hover-tracking
on a plain outer `View` with `onPointerEnter`/`onPointerLeave`, nested
Pressables freely inside. Pointer events are non-bubbling DOM events,
so descendants can't disrupt the outer hover.

Rewrite `docs/hover.md` with the workspace row as THE canonical pattern,
not one option among several. Names the three failure modes that come
from skipping it — including "nested Pressables fight over hover" — and
explicitly bounds when `Pressable.onHoverIn`/`onHoverOut` is acceptable
(self-styling only).

Update `constants/platform.ts` so its hover guidance points at the doc
instead of recommending the broken pattern.
2026-05-15 11:25:19 +07:00
Mohamed Boudra
29d46c5d92 test: allow checkout context logger 2026-05-15 10:28:41 +07:00
Mohamed Boudra
a72c7f6c4b Use daemon timestamps for turn timing (#1033) 2026-05-15 11:09:13 +08:00
Mohamed Boudra
1962225647 Document forwarded skills in paseo-advisor
When the user invokes /paseo-advisor with another skill reference
(/unslop, /unslop-risk, $diagnose, etc.), the orchestrator should
instruct the advisor to load and invoke that skill against the current
task before doing anything else.
2026-05-15 09:50:05 +07:00
Mohamed Boudra
2d3c9101f3 Trace git command spawn and close metadata
Thread an optional logger through runGitCommand and the CheckoutContext
helpers so workspace-git-service can emit trace-level traces around each
git invocation. Behavior is unchanged when no logger is supplied.
2026-05-15 09:50:05 +07:00
Mohamed Boudra
f27d39fb63 Normalize default thinking options (#1028) 2026-05-15 00:11:56 +08:00
Mohamed Boudra
5a3fc96b76 simplify contributing.md 2026-05-15 00:09:52 +08:00
Mohamed Boudra
fbeda4510e Fix duplicate project worktree registration (#1024)
* Fix duplicate project worktree registration

* Update archive redirect test for project-aware routes
2026-05-14 20:57:32 +08:00
Mohamed Boudra
7e255a5249 Fix assistant file link resolution (#1025) 2026-05-14 20:55:41 +08:00
Mohamed Boudra
a19b780eca Fix OpenCode retry timeout handling (#1026) 2026-05-14 20:55:19 +08:00
Mohamed Boudra
faa0d93cb8 Fix ahead count when upstream branch is gone (#1023) 2026-05-14 19:10:14 +08:00
Mohamed Boudra
2dd77aae04 Auto-archive worktrees after PR merge (#1004)
Add a daemon setting `autoArchiveAfterMerge` (default off). When on, the
daemon's existing GitHub poll triggers archive once the PR is merged,
but only if the worktree is clean (not dirty, not ahead of origin).
Skips silently otherwise.
2026-05-14 10:08:48 +00:00
Mohamed Boudra
2e8bced707 Auto-attach pasted GitHub PR/issue URLs in the composer (#1006)
* Auto-attach pasted GitHub PR/issue URLs in the composer

Paste a GitHub PR or issue link into any composer and it shows up
as an attachment pill within a debounce tick when the URL matches
the workspace's remote. On the new-workspace screen, an attached PR
also surfaces an inline "Check out PR #N?" hint next to the picker;
one click promotes it to the checkout target through the same
syncPickerPrAttachment flow the dropdown uses.

Unifies the new-workspace picker and composer attachment picker on
one shared GitHub search hook and cache key, so both consumers (and
the auto-attach lookup) share the React Query cache.

* Fix oxfmt formatting in new-workspace-screen import
2026-05-14 17:52:46 +08:00
Mohamed Boudra
8296c797ff Project rename: distinguishable names for duplicate projects (#1003)
* Add project rename so duplicates get distinguishable names

Two checkouts of the same remote collapse into a single project record
(same projectId), which left users unable to tell duplicates apart in
the UI. customName lives alongside the derived displayName as an
override that reconciliation never touches, so renames persist across
git-remote changes. The rename UI lives in project settings.

Closes #987 (rename half).

* Fix COMPAT(projectCustomName) version to v0.1.76

* Update useProjects shape test for new projectCustomName key

* Use sendCorrelatedSessionRequest for renameProject

Match the pattern used by checkoutPrMerge and other newer RPCs
instead of hand-rolling the select callback.

* Use project.rename.{request,response} dotted RPC names

Match the convention from docs/rpc-namespacing.md (added in 75a6f8277).
Schemas, type exports, daemon client, session handler, and tests all
move to the namespaced names. No back-compat shim needed since the RPC
hasn't shipped.
2026-05-14 17:49:24 +08:00
Mohamed Boudra
e1ab779fe0 Fix Shift+Enter in terminal input modes (#999)
* Fix Shift+Enter terminal input modes

* Advertise terminal input capabilities
2026-05-14 17:48:22 +08:00
Mohamed Boudra
eb7fa02ac8 Surface GitHub auto-merge actions (#1001)
* Surface GitHub auto-merge actions

* Namespace GitHub auto-merge RPC

* Update workspace git refresh test expectation

* Avoid redundant GitHub refresh queue
2026-05-14 17:48:05 +08:00
YuKuang
44292b2b69 fix: honor current branch in new workspace flow (#908)
* fix: honor current branch in new workspace flow

* Use OpenCode global event stream (#916)

* Use OpenCode global event stream

* Use stable OpenCode model in initial prompt e2e

* Clean up OpenCode verification notes

* chore: changelog for 0.1.74

* chore(release): cut 0.1.74

* Fix custom Codex provider base URL routing (#915)

* Wire Copilot Allow All mode to ACP permissions (#935)

* Add trace logging and tighten daemon log defaults (#933)

* Add trace logging and tighten daemon log defaults

* Clean up daemon trace logging shape

* Clean up daemon trace logging

* Route provider turn-id checks through shared helper

* Fix supervisor log config test paths on Windows

* Expect resolved supervisor log path on Windows

* nix: declarative config, typed relay options, desktop packaging (#923)

* nix: expose npmDepsHash as a callPackage arg

Downstream flakes that follow a different nixpkgs revision can hit a
hash mismatch on the npm-deps FOD even though package-lock.json is
unchanged, because fetchNpmDeps output is sensitive to nixpkgs version.
The standard fix — `.overrideAttrs { npmDepsHash = ...; }` — does not
work for buildNpmPackage: npmDepsHash is destructured from args, so the
default `npmDeps = fetchNpmDeps { hash = npmDepsHash; }` is already
bound by the time overrideAttrs runs.

Promote npmDepsHash to a callPackage arg with the current value as the
default. Consumers can now `.override { npmDepsHash = "sha256-..."; }`
and have it propagate to the npmDeps fetcher. Upstream CI behavior is
unchanged — update-nix.sh is adjusted to match the new
`npmDepsHash ? "..."` pattern.

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

* nix: move npmDepsHash default to a sidecar file

Read the default `npmDepsHash` from `nix/npm-deps.hash` via
`lib.fileContents` instead of inlining it as a string literal in
`nix/package.nix`. The CI auto-updater becomes a one-line file write
instead of a regex against a .nix source — decoupling lockfile bumps
from the formatting of the package definition.

No behavior change: same hash, same default, same `.override` surface.
Lockfile diffs become smaller and the update path stops being load-
bearing on a sed pattern.

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

* nix: declarative config via services.paseo.settings

Today only a handful of `config.json` fields are surfaced as module
options (listen, port, hostnames, relay.enable). Anything richer —
custom agent providers, MCP injection, log config, voice features —
requires hand-editing `$PASEO_HOME/config.json`.

Add `services.paseo.settings` as a freeform attrset rendered to JSON
via `pkgs.formats.json` and installed at `$PASEO_HOME/config.json`
on each service start. Standard NixOS idiom.

`install` on `preStart` rather than a `tmpfiles` symlink because the
daemon writes to `config.json` at runtime via `DaemonConfigStore.patch`
(MCP / provider toggles). A read-only symlink would break those writes;
a copy-on-start lets the daemon mutate freely within a session while
the Nix-managed file remains the source of truth at boot.

The full schema is `PersistedConfigSchema` in
`packages/server/src/server/persisted-config.ts`. Documented in the
option description that runtime mutations don't survive restarts when
`settings` is non-empty.

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

* nix: typed services.paseo.relay options with auto-wired endpoint

Addresses #224 (option surface only).

Today `services.paseo.relay.enable` is a bool that just toggles
`--no-relay`. Pointing the daemon at a self-hosted relay requires
hand-setting `PASEO_RELAY_ENDPOINT` and `PASEO_RELAY_USE_TLS` via
the freeform `environment` option.

Add a typed relay subtree:

- `relay.mode = "hosted" | "remote"` selects how the daemon reaches
  the relay when enabled. Default is `"hosted"` (current behavior).
- `relay.{host,port,useTls}` configure the `"remote"` case.
- The module auto-wires `PASEO_RELAY_ENDPOINT` and `PASEO_RELAY_USE_TLS`
  when `mode = "remote"`.
- Assertion fires at eval time when `mode = "remote"` but `host` is empty.
- `relay.enable` keeps its current semantics — bool answers "is it on?",
  the new options answer "how is it configured?".

The `"local"` mode from #224 (running a relay on the same host as a
systemd unit) is deliberately not added here: `packages/relay` ships
only a Cloudflare Workers adapter, so there's no Node.js runtime to
package as a binary. Adding a Node adapter is a TS-side feature change
worth its own design discussion; tracked as a follow-up.

No breaking changes — existing `relay.enable = true|false` configs
evaluate unchanged with the new `mode = "hosted"` default.

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

* nix: package paseo desktop app for Linux

NixOS users have no easy way to run the desktop app today —
electron-builder's outputs (.deb, .rpm, .AppImage) don't fit Nix's
model, so `nix run github:getpaseo/paseo#desktop` doesn't exist.

Add `packages.<linux>.desktop` following the standard nixpkgs Electron
pattern (see e.g. signal-desktop, vscode): skip electron-builder
entirely, build the desktop main process with `tsc`, bundle the Expo
web export and built daemon workspaces, and wrap `pkgs.electron` with
`makeWrapper`. Output is a runnable derivation usable via `nix run` or
`environment.systemPackages`.

The install layout preserves the monorepo source tree
(`packages/desktop/dist/main.js`, `packages/app/dist`, `node_modules`
at the workspace root) so `main.ts`'s dev-mode path resolution
(`__dirname/../../app/dist`, `__dirname/../assets/icon.png`) works
without any source patches. When Electron is invoked unpackaged via
`electron path/to/main.js`, `app.isPackaged` is false and these
relative paths are used.

`--no-sandbox` is set on the launcher: Chromium's setuid sandbox can't
live in `/nix/store` (immutable, no setuid). A follow-up can wire
`security.wrappers` from a NixOS module for users who want the
renderer sandbox.

No CI changes — `desktop-release.yml` continues to produce
.deb/.AppImage/.rpm/macOS/Windows installers as today. This is purely
additive for NixOS users.

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

* nix: copy full packages/ tree in desktop derivation

The previous installPhase selectively copied built artifacts (dist/
under server, cli, relay, highlight, expo-two-way-audio), which left
two workspace symlinks dangling and failed noBrokenSymlinks:

- node_modules/@getpaseo/expo-two-way-audio → packages/expo-two-way-audio
  (the Expo native module ships source + native projects, no built dist/)
- node_modules/.bin/paseo → @getpaseo/cli/bin/paseo
  (the CLI launcher script lives under bin/, not dist/)

npm workspace symlinks expect every workspace package to exist at its
source path. Copy the whole packages/ tree instead. The cleanSourceWith
filter already excludes the heavy platform-specific paths (android/ios
under packages/app, website, tests), and the remaining ~16MB of src is
acceptable for an Electron app derivation.

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

* nix: route desktop renderer through paseo:// protocol handler

When `paseo-desktop` is launched via `electron path/to/main.js` (our
unpackaged Nix layout), `app.isPackaged` is false and main.ts loads
`DEV_SERVER_URL` — which defaults to http://localhost:8081 (the Expo
dev server). That URL has nothing listening in a Nix-installed run,
so the renderer fails with ERR_CONNECTION_REFUSED.

main.ts already supports overriding this via the `EXPO_DEV_URL` env
var. Set it to `paseo://app/` so the request goes through the
`paseo://` protocol handler that main.ts registers unconditionally.
The handler resolves files via `getAppDistDir()`, which in the
unpackaged branch returns `__dirname/../../app/dist` — exactly where
our install layout places the Expo web export.

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

* ci: track nix/npm-deps.hash in nix-build commit step

The commit step still referenced nix/package.nix in its diff check
and git add. After moving the hash to nix/npm-deps.hash, the
auto-updated hash would never be staged and the new value would
sit unstaged in the working tree forever.

* ci: push nix-build hash commits via paseo-ai[bot] App token

The default GITHUB_TOKEN cannot bypass main's required status checks,
so the auto-commit of stale Nix hash updates has been silently failing.
Mint an installation token for the paseo-ai App (which is in the
ruleset bypass list) and use it for checkout and push.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>

* Normalize HEIC image attachments on the client (#934)

* Normalize HEIC attachments before persistence

* Preserve native JPEG and PNG picks

* fix: update lockfile signatures and Nix hash

* ci: skip App token on PRs in nix-build

Fork PRs cannot read repo secrets, so the App-token step failed with
'Input required and not supplied: app-id'. Gate the App-token step on
push-to-main and fall back to github.token for the checkout on PRs,
since PRs only need to validate the build.

* ci: skip CI on nix-build auto hash commits

The hash-update commit only changes package-lock.json and
nix/npm-deps.hash. The parent commit already passed all 13 required
checks, and the file diff is content-only, so re-running the full
suite on every hash bump is wasted CI minutes.

* ci: drop unused contents: write override in nix-build

GITHUB_TOKEN no longer pushes anything in this workflow. PR runs are
read-only, and main pushes go through the paseo-ai App token. The
workflow-level 'contents: read' is sufficient.

Addresses #365.

* ci: gate nix-build on same-repo PRs only

scripts/update-nix.sh and scripts/fix-lockfile.mjs are executed from
the PR's checked-out tree. A fork PR can modify those scripts to run
arbitrary code on the runner. Same-repo PRs and push-to-main still
run; fork PRs skip the job entirely.

Closes #365.

* ci: split nix workflow into build (anywhere) + update-hash (main only)

build runs on push to main and every PR including forks. Same model as
the test/lint/typecheck workflows: read-only token, no secrets injected,
runs scripts/update-nix.sh + nix build to validate. No commit, no push.

update-hash runs only on push to main. Mints the paseo-ai App token,
re-runs the update + build, and commits the refreshed hash via the
ruleset bypass. The default GITHUB_TOKEN no longer pushes anywhere.

Rename file to nix.yml since it's no longer just a build job.

* ci: split nix into two workflows

nix.yml: PR-only build check.
nix-update-hash.yml: push-to-main hash update via paseo-ai App token.

* Fix iPad sidebar safe area background (#937)

Co-authored-by: zbl <zbl@zbl-M4Pro.local>

* Fix desktop daemon stale PID startup (#913)

* Configure STT language from settings (#941)

* Configure STT language from settings

* Update websocket speech mock for language config

* Handle Windows shell wrappers in Codex command summaries (#931)

* fix(app): respect iPad safe area in settings sidebar (#922)

Co-authored-by: zbl <zbl@zbl-M4Pro.local>

* ci(nix): smoke test daemon boot (#939)

* Fix scheduled agent cleanup (#945)

* Fail Codex resume requests explicitly (#947)

* fix(copilot): preserve legacy autopilot mode alias

* docs(changelog): draft 0.1.75 entry

* chore(release): cut 0.1.75

* fix: update lockfile signatures and Nix hash [skip ci]

* docs(changelog): refine 0.1.75 entry

* fix: preserve worktree action literal types

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
Co-authored-by: Matan Bendix Shenhav <matan@shenhav.fyi>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: paseo-ai[bot] <266920839+paseo-ai[bot]@users.noreply.github.com>
Co-authored-by: Bolun Zhang <zhaggbl@mail.ustc.edu.cn>
Co-authored-by: zbl <zbl@zbl-M4Pro.local>
Co-authored-by: Biao Ma <sigemaict@gmail.com>
Co-authored-by: ezra <ezra.ora.c@gmail.com>
2026-05-13 17:34:57 +00:00
Mohamed Boudra
05c12a9c5c Fix branch-off worktree upstream tracking (#1002) 2026-05-13 17:06:51 +00:00
Mohamed Boudra
5c7a6397da Pin /epic to Paseo agents, not harness subagents 2026-05-13 22:35:29 +07:00
Mohamed Boudra
f1e5640a26 Confirm risky worktree archive (#994) 2026-05-13 23:27:49 +08:00
Mohamed Boudra
512702fe46 Avoid probing extra executable candidates (#996) 2026-05-13 15:14:47 +00:00
Mohamed Boudra
ecc6a269bc Merge branch 'main' of github.com:getpaseo/paseo 2026-05-13 22:14:23 +07:00
Mohamed Boudra
7a0606952d Add Electron renderer remote debugging for profiling 2026-05-13 22:13:47 +07:00
Mohamed Boudra
68fae16740 Recover Pi sessions after Copilot 413 (#995) 2026-05-13 15:00:22 +00:00
Mohamed Boudra
bc329a2d85 Show all PR check counts in hover card (#993) 2026-05-13 14:02:39 +00:00
Mohamed Boudra
e96cd16a0f Deduplicate Codex skill commands (#992) 2026-05-13 13:58:31 +00:00
Mohamed Boudra
316eeee9ca Fix ACP diagnostic model counting (#991) 2026-05-13 13:54:34 +00:00
Mohamed Boudra
c3012ea520 Add Codex context compaction support (#990) 2026-05-13 13:52:21 +00:00
Mohamed Boudra
e2ecae0e72 Preserve Codex assistant message ids (#989) 2026-05-13 13:18:31 +00:00
Mohamed Boudra
49fa72d70d Make provider snapshot refresh explicit (#988) 2026-05-13 20:54:07 +08:00
Bolun Zhang
c6534b3a16 Add MCP provider feature discovery (#910)
Refs #835
2026-05-13 20:12:10 +08:00
Bolun Zhang
15631b815b Add MCP provider feature controls (#909)
Refs #835
2026-05-13 20:05:52 +08:00
paseo-ai[bot]
d30a3a72da fix: update lockfile signatures and Nix hash [skip ci] 2026-05-13 10:52:09 +00:00
Chuan
6ed7dfc42a fix(server): include OpenCode console subscription providers in model list (#917)
* fix(server): include OpenCode console subscription providers in model list

Providers with source "api" (e.g. Pi coding agent via OpenCode Go
subscription) appear in providers.all but not in providers.connected,
which only lists env/config providers. Paseo was filtering exclusively
by connected, so subscription-managed providers were silently dropped
and users saw an empty model list or a misleading "set env vars" error.

Fix: treat source === "api" as accessible in both listModels() and the
context-window lookup helper. Also update the error message to mention
the console login option.

chore: add portless as a devDependency so npm run dev works without
a global install.

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

* chore: remove portless dependency

* test(server): add unit tests for OpenCode api-source provider inclusion

Covers the source="api" logic added in 08e3c6bf: providers managed by the
OpenCode console/subscription must appear in listModels and the context window
lookup even when absent from the connected list.

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

* chore: drop unrelated package-lock.json churn

The lockfile changes were peer metadata additions from a newer npm
version, not actual dependency changes. Restore to match main.

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-05-13 18:48:00 +08:00
João Sousa Andrade
77dc80b86f Patch compatible transitive advisories (#924)
* Patch compatible transitive advisories

* Fix npm ci lockfile mismatch
2026-05-13 18:47:54 +08:00
Mohamed Boudra
2bd2e8bc46 feat(claude): discover models from settings
Fixes #649, #475, #455, #558
2026-05-13 18:47:50 +08:00
Bolun Zhang
81697f85b2 fix(app): iPad hardware Enter submits the composer
Fixes #810
2026-05-13 17:52:39 +08:00
ezra
72293f7f4f fix(server): fall back PR status queries for fine-grained tokens
Fixes #926
2026-05-13 17:52:13 +08:00
Mohamed Boudra
65d14d4433 Show upstream ACP JSON-RPC errors instead of [object Object]
* Preserve ACP JSON-RPC error details

* Unslop ACP error summarization
2026-05-13 17:43:40 +08:00
Chris Banes
9eb1ddf29d fix(cursor): discover models via Cursor ACP client
Fixes #925
2026-05-13 17:12:53 +08:00
Mohamed Boudra
d18bfe9047 Add local settings to Claude configuration (#977) 2026-05-13 16:34:49 +08:00
Mohamed Boudra
c741185100 Remove personal-skill references from repo skills
Make the repo's paseo skills self-contained — drop mentions of
personal skills (unslop, think-harder, e2e-playwright, fix-build,
rebase) that aren't checked in here.
2026-05-13 15:06:31 +07:00
Mohamed Boudra
ea0bb81100 fix(server): reconcile workspace kind alongside project kind (#975)
The reconciler updated project.kind when a directory became a git repo
but never updated the workspace.kind underneath, leaving the sidebar in
a split-brain state: an expandable git project containing a workspace
with no leading icon (workspaceKind stayed "directory" instead of
flipping to "local_checkout" or "worktree").
2026-05-13 15:58:48 +08:00
Mohamed Boudra
9d7d1e8500 Fix zsh integration runtime file modes (#976) 2026-05-13 15:58:20 +08:00
c4605
16c27d7404 Add Auto Review permission mode for Claude Code (#928)
* Add Auto Review permission mode

* Align Codex Auto Review with auto permissions

* Defer Codex Auto Review to dedicated PR

* fix(app): drop unconditional snapshot refresh on mode selector open

Opening the mode selector was reusing the model selector refresh path, which forced a provider snapshot refresh every time. That made the agent status bar briefly lose its selected provider data and flash empty while the picker opened.

Keep the stale model refresh on the model selector only, and let the mode selector open from the existing snapshot state.

* refactor(claude): rename Claude auto permission mode label to "Auto mode"

Claude Code itself calls this permission setting Auto mode in its CLI/TUI, so match that label in Paseo. The supporting test names and transport eligibility errors now use the same terminology.

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-05-13 13:58:26 +08:00
Mohamed Boudra
b8a3eefd47 feat(codex): implement Auto-review permission mode (#963)
* feat(codex): plumb auto-review approvals reviewer

* feat(codex): add Auto-review mode visuals

* test(codex): cover Auto-review mode plumbing

* test(codex): prove Auto-review approval routing
2026-05-13 13:58:15 +08:00
Mohamed Boudra
d24087c10a Fix relay E2EE reconnect races (#973)
* Fix relay E2EE reconnect races

* fix(relay): use WebSocket protocol ping for daemon control keepalive

The daemon's control socket pinged the relay every 10s with a JSON
`{type:"ping"}` message. Every JSON message wakes a hibernated Cloudflare
Durable Object via `webSocketMessage`, so an idle daemon was billing
relay DO CPU 24/7 just to echo `pong`.

Switch to `socket.ping()` (RFC 6455 protocol ping frames). Cloudflare's
runtime auto-responds at the edge without waking hibernated DOs, so an
idle daemon-to-relay connection now costs zero DO CPU.

Liveness still works: the `ws` library emits a `pong` event on the
daemon when the edge replies, which refreshes `controlLastSeenAt`.
`markControlReady` stays tied to DO-originated messages (sync,
connected, disconnected), so we still detect a broken DO at connect.

The DO keeps its JSON ping handler tagged `COMPAT(relay-json-ping)` so
old daemons (< v0.1.76) continue to work.

* test(relay): assert protocol-ping pongs arrive in e2e idle test

Adds a `relay_control_pong_received` debug log to the daemon's pong
handler, then asserts in the existing idle-relay e2e test that:

  - at least one pong arrived during the 12s idle wait (proving the
    Cloudflare edge auto-responds to RFC 6455 protocol pings)
  - no stale terminations fired (proving the keepalive path is healthy)

If the daemon ever regresses to JSON-level keepalive pings (which wake
the hibernated relay Durable Object and incur CPU billing), this test
will catch it: the `pong` event would never fire because the DO's reply
would arrive via `message`, not as a protocol pong frame.

The test's capturing logger is bumped to `debug` level so the assertion
can read the new debug log; production daemons stay at `info`.

Confirmed locally against wrangler-hosted relay: 2 pongs / 0 stale per
12s window.

* test(relay): catch dual-ping regression via DO-side legacy ping log

The existing idle-test assertion catches "revert to JSON ping only" via
the missing pong event, but misses the dual-ping regression where
someone adds `socket.send(JSON.stringify({type:"ping"}))` alongside
`socket.ping()`. Both paths would work, the pong assertion still
passes, and DO CPU billing silently returns.

Add a console.log in the DO's handleControlKeepalive (which only runs
when a JSON ping reaches the DO), then assert in the idle e2e test
that no such log appeared during the 12s idle window.

Verified the assertion catches the regression: temporarily injected a
`socket.send(JSON.stringify({type:"ping"}))` next to socket.ping(),
test failed with `expected 1 to be 0` and the relay logged
`legacy_json_ping_received`. Removed the injection.

Log noise during the COMPAT window doubles as useful telemetry: it
counts how many old daemons are still pinging, which informs the
removal date for the COMPAT shim.
2026-05-13 13:44:11 +08:00
Bolun Zhang
15cab6014c Refresh MCP worktree cache after create/archive (#911)
* Fix MCP worktree list cache after create/archive

* Fix MCP worktree archive refresh from scoped cwd

---------

Co-authored-by: zbl <zbl@zbl-M4Pro.local>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-05-12 22:17:17 +08:00
Mohamed Boudra
47d236a299 Add diagnostics for generic ACP providers (#955) 2026-05-12 21:24:03 +08:00
Mohamed Boudra
b9cea49f10 fix(app): auto-set ref picker from a single attached PR (#951)
* fix(app): auto-set ref picker from a single attached PR

Attaching a PR in the new-workspace composer signals "I want this PR's
context", and almost always also means "base the worktree on this PR".
Until now the two paths were independent: the ref picker stayed on the
default branch, so the worktree was branched off main even though the
sidebar chip looked like a PR checkout.

Auto-promote the picker when exactly one PR is attached and the user
hasn't manually picked a ref. Manual picker selections are preserved;
auto-promoted ones are cleared when their backing attachment is removed.

* refactor(app): derive auto-promoted picker selection instead of syncing

The previous version drove `pickerSelection` from a useEffect that
inspected attachments and overwrote state. That meant the shared
`PickerSelection` type had to grow a `source: "manual" | "attachment"`
discriminator just so the effect could tell its own writes apart from
manual picks. Classic effect-as-derived-state.

Split the state instead. `manualPickerSelection` holds explicit picks;
`autoPickerItem` is a useMemo over composer attachments; the effective
`pickerSelection` is `manual ?? auto` via a small precedence helper. No
effect, no `source` flag, and `PickerSelection` stays a screen-local
shape since nothing outside needs it.
2026-05-12 21:08:47 +08:00
Mohamed Boudra
ad88682426 docs(changelog): refine 0.1.75 entry 2026-05-12 17:59:59 +07:00
paseo-ai[bot]
1ddbdf54f1 fix: update lockfile signatures and Nix hash [skip ci] 2026-05-12 10:40:31 +00:00
Mohamed Boudra
51d9563352 chore(release): cut 0.1.75 2026-05-12 17:35:22 +07:00
Mohamed Boudra
045b373168 docs(changelog): draft 0.1.75 entry 2026-05-12 17:34:18 +07:00
Mohamed Boudra
985ad52cce fix(copilot): preserve legacy autopilot mode alias 2026-05-12 17:34:09 +07:00
Mohamed Boudra
d198c68b9e Fail Codex resume requests explicitly (#947) 2026-05-12 17:50:31 +08:00
Mohamed Boudra
751a07124f Fix scheduled agent cleanup (#945) 2026-05-12 17:05:40 +08:00
Mohamed Boudra
defb4f82f7 ci(nix): smoke test daemon boot (#939) 2026-05-12 16:05:15 +08:00
Bolun Zhang
4570e65ce8 fix(app): respect iPad safe area in settings sidebar (#922)
Co-authored-by: zbl <zbl@zbl-M4Pro.local>
2026-05-12 07:27:49 +00:00
ezra
db0d63dd90 Handle Windows shell wrappers in Codex command summaries (#931) 2026-05-12 15:07:37 +08:00
Mohamed Boudra
1a8fdcd388 Configure STT language from settings (#941)
* Configure STT language from settings

* Update websocket speech mock for language config
2026-05-12 15:03:53 +08:00
Biao Ma
417abed6a5 Fix desktop daemon stale PID startup (#913) 2026-05-12 14:33:18 +08:00
Bolun Zhang
6afdeef84a Fix iPad sidebar safe area background (#937)
Co-authored-by: zbl <zbl@zbl-M4Pro.local>
2026-05-12 14:24:20 +08:00
Mohamed Boudra
77c82dfdbd ci: split nix into two workflows
nix.yml: PR-only build check.
nix-update-hash.yml: push-to-main hash update via paseo-ai App token.
2026-05-12 12:57:38 +07:00
Mohamed Boudra
29277c900d ci: split nix workflow into build (anywhere) + update-hash (main only)
build runs on push to main and every PR including forks. Same model as
the test/lint/typecheck workflows: read-only token, no secrets injected,
runs scripts/update-nix.sh + nix build to validate. No commit, no push.

update-hash runs only on push to main. Mints the paseo-ai App token,
re-runs the update + build, and commits the refreshed hash via the
ruleset bypass. The default GITHUB_TOKEN no longer pushes anywhere.

Rename file to nix.yml since it's no longer just a build job.
2026-05-12 12:54:22 +07:00
Mohamed Boudra
ed1943058a ci: gate nix-build on same-repo PRs only
scripts/update-nix.sh and scripts/fix-lockfile.mjs are executed from
the PR's checked-out tree. A fork PR can modify those scripts to run
arbitrary code on the runner. Same-repo PRs and push-to-main still
run; fork PRs skip the job entirely.

Closes #365.
2026-05-12 12:37:01 +07:00
Mohamed Boudra
e0361ddd22 ci: drop unused contents: write override in nix-build
GITHUB_TOKEN no longer pushes anything in this workflow. PR runs are
read-only, and main pushes go through the paseo-ai App token. The
workflow-level 'contents: read' is sufficient.

Addresses #365.
2026-05-12 12:35:05 +07:00
Mohamed Boudra
ce9474055e ci: skip CI on nix-build auto hash commits
The hash-update commit only changes package-lock.json and
nix/npm-deps.hash. The parent commit already passed all 13 required
checks, and the file diff is content-only, so re-running the full
suite on every hash bump is wasted CI minutes.
2026-05-12 12:28:34 +07:00
Mohamed Boudra
8f9b4c8828 ci: skip App token on PRs in nix-build
Fork PRs cannot read repo secrets, so the App-token step failed with
'Input required and not supplied: app-id'. Gate the App-token step on
push-to-main and fall back to github.token for the checkout on PRs,
since PRs only need to validate the build.
2026-05-12 12:26:22 +07:00
paseo-ai[bot]
af4e0de9ab fix: update lockfile signatures and Nix hash 2026-05-12 05:21:32 +00:00
Mohamed Boudra
3acc71b8ad Normalize HEIC image attachments on the client (#934)
* Normalize HEIC attachments before persistence

* Preserve native JPEG and PNG picks
2026-05-12 13:14:25 +08:00
Matan Bendix Shenhav
0759932dad nix: declarative config, typed relay options, desktop packaging (#923)
* nix: expose npmDepsHash as a callPackage arg

Downstream flakes that follow a different nixpkgs revision can hit a
hash mismatch on the npm-deps FOD even though package-lock.json is
unchanged, because fetchNpmDeps output is sensitive to nixpkgs version.
The standard fix — `.overrideAttrs { npmDepsHash = ...; }` — does not
work for buildNpmPackage: npmDepsHash is destructured from args, so the
default `npmDeps = fetchNpmDeps { hash = npmDepsHash; }` is already
bound by the time overrideAttrs runs.

Promote npmDepsHash to a callPackage arg with the current value as the
default. Consumers can now `.override { npmDepsHash = "sha256-..."; }`
and have it propagate to the npmDeps fetcher. Upstream CI behavior is
unchanged — update-nix.sh is adjusted to match the new
`npmDepsHash ? "..."` pattern.

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

* nix: move npmDepsHash default to a sidecar file

Read the default `npmDepsHash` from `nix/npm-deps.hash` via
`lib.fileContents` instead of inlining it as a string literal in
`nix/package.nix`. The CI auto-updater becomes a one-line file write
instead of a regex against a .nix source — decoupling lockfile bumps
from the formatting of the package definition.

No behavior change: same hash, same default, same `.override` surface.
Lockfile diffs become smaller and the update path stops being load-
bearing on a sed pattern.

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

* nix: declarative config via services.paseo.settings

Today only a handful of `config.json` fields are surfaced as module
options (listen, port, hostnames, relay.enable). Anything richer —
custom agent providers, MCP injection, log config, voice features —
requires hand-editing `$PASEO_HOME/config.json`.

Add `services.paseo.settings` as a freeform attrset rendered to JSON
via `pkgs.formats.json` and installed at `$PASEO_HOME/config.json`
on each service start. Standard NixOS idiom.

`install` on `preStart` rather than a `tmpfiles` symlink because the
daemon writes to `config.json` at runtime via `DaemonConfigStore.patch`
(MCP / provider toggles). A read-only symlink would break those writes;
a copy-on-start lets the daemon mutate freely within a session while
the Nix-managed file remains the source of truth at boot.

The full schema is `PersistedConfigSchema` in
`packages/server/src/server/persisted-config.ts`. Documented in the
option description that runtime mutations don't survive restarts when
`settings` is non-empty.

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

* nix: typed services.paseo.relay options with auto-wired endpoint

Addresses #224 (option surface only).

Today `services.paseo.relay.enable` is a bool that just toggles
`--no-relay`. Pointing the daemon at a self-hosted relay requires
hand-setting `PASEO_RELAY_ENDPOINT` and `PASEO_RELAY_USE_TLS` via
the freeform `environment` option.

Add a typed relay subtree:

- `relay.mode = "hosted" | "remote"` selects how the daemon reaches
  the relay when enabled. Default is `"hosted"` (current behavior).
- `relay.{host,port,useTls}` configure the `"remote"` case.
- The module auto-wires `PASEO_RELAY_ENDPOINT` and `PASEO_RELAY_USE_TLS`
  when `mode = "remote"`.
- Assertion fires at eval time when `mode = "remote"` but `host` is empty.
- `relay.enable` keeps its current semantics — bool answers "is it on?",
  the new options answer "how is it configured?".

The `"local"` mode from #224 (running a relay on the same host as a
systemd unit) is deliberately not added here: `packages/relay` ships
only a Cloudflare Workers adapter, so there's no Node.js runtime to
package as a binary. Adding a Node adapter is a TS-side feature change
worth its own design discussion; tracked as a follow-up.

No breaking changes — existing `relay.enable = true|false` configs
evaluate unchanged with the new `mode = "hosted"` default.

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

* nix: package paseo desktop app for Linux

NixOS users have no easy way to run the desktop app today —
electron-builder's outputs (.deb, .rpm, .AppImage) don't fit Nix's
model, so `nix run github:getpaseo/paseo#desktop` doesn't exist.

Add `packages.<linux>.desktop` following the standard nixpkgs Electron
pattern (see e.g. signal-desktop, vscode): skip electron-builder
entirely, build the desktop main process with `tsc`, bundle the Expo
web export and built daemon workspaces, and wrap `pkgs.electron` with
`makeWrapper`. Output is a runnable derivation usable via `nix run` or
`environment.systemPackages`.

The install layout preserves the monorepo source tree
(`packages/desktop/dist/main.js`, `packages/app/dist`, `node_modules`
at the workspace root) so `main.ts`'s dev-mode path resolution
(`__dirname/../../app/dist`, `__dirname/../assets/icon.png`) works
without any source patches. When Electron is invoked unpackaged via
`electron path/to/main.js`, `app.isPackaged` is false and these
relative paths are used.

`--no-sandbox` is set on the launcher: Chromium's setuid sandbox can't
live in `/nix/store` (immutable, no setuid). A follow-up can wire
`security.wrappers` from a NixOS module for users who want the
renderer sandbox.

No CI changes — `desktop-release.yml` continues to produce
.deb/.AppImage/.rpm/macOS/Windows installers as today. This is purely
additive for NixOS users.

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

* nix: copy full packages/ tree in desktop derivation

The previous installPhase selectively copied built artifacts (dist/
under server, cli, relay, highlight, expo-two-way-audio), which left
two workspace symlinks dangling and failed noBrokenSymlinks:

- node_modules/@getpaseo/expo-two-way-audio → packages/expo-two-way-audio
  (the Expo native module ships source + native projects, no built dist/)
- node_modules/.bin/paseo → @getpaseo/cli/bin/paseo
  (the CLI launcher script lives under bin/, not dist/)

npm workspace symlinks expect every workspace package to exist at its
source path. Copy the whole packages/ tree instead. The cleanSourceWith
filter already excludes the heavy platform-specific paths (android/ios
under packages/app, website, tests), and the remaining ~16MB of src is
acceptable for an Electron app derivation.

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

* nix: route desktop renderer through paseo:// protocol handler

When `paseo-desktop` is launched via `electron path/to/main.js` (our
unpackaged Nix layout), `app.isPackaged` is false and main.ts loads
`DEV_SERVER_URL` — which defaults to http://localhost:8081 (the Expo
dev server). That URL has nothing listening in a Nix-installed run,
so the renderer fails with ERR_CONNECTION_REFUSED.

main.ts already supports overriding this via the `EXPO_DEV_URL` env
var. Set it to `paseo://app/` so the request goes through the
`paseo://` protocol handler that main.ts registers unconditionally.
The handler resolves files via `getAppDistDir()`, which in the
unpackaged branch returns `__dirname/../../app/dist` — exactly where
our install layout places the Expo web export.

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

* ci: track nix/npm-deps.hash in nix-build commit step

The commit step still referenced nix/package.nix in its diff check
and git add. After moving the hash to nix/npm-deps.hash, the
auto-updated hash would never be staged and the new value would
sit unstaged in the working tree forever.

* ci: push nix-build hash commits via paseo-ai[bot] App token

The default GITHUB_TOKEN cannot bypass main's required status checks,
so the auto-commit of stale Nix hash updates has been silently failing.
Mint an installation token for the paseo-ai App (which is in the
ruleset bypass list) and use it for checkout and push.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-05-12 13:11:42 +08:00
Mohamed Boudra
95f45e4e2b Add trace logging and tighten daemon log defaults (#933)
* Add trace logging and tighten daemon log defaults

* Clean up daemon trace logging shape

* Clean up daemon trace logging

* Route provider turn-id checks through shared helper

* Fix supervisor log config test paths on Windows

* Expect resolved supervisor log path on Windows
2026-05-12 12:58:46 +08:00
Mohamed Boudra
a5c2b97e1d Wire Copilot Allow All mode to ACP permissions (#935) 2026-05-12 12:00:20 +08:00
Mohamed Boudra
d32462e9ee Fix custom Codex provider base URL routing (#915) 2026-05-12 11:54:15 +08:00
Mohamed Boudra
33262843a5 chore(release): cut 0.1.74 2026-05-11 18:17:50 +07:00
Mohamed Boudra
1cd02a0e1a chore: changelog for 0.1.74 2026-05-11 18:16:46 +07:00
Mohamed Boudra
40ab9e3f20 Use OpenCode global event stream (#916)
* Use OpenCode global event stream

* Use stable OpenCode model in initial prompt e2e

* Clean up OpenCode verification notes
2026-05-11 09:17:00 +00:00
Mohamed Boudra
4141c76258 chore(release): cut 0.1.73 2026-05-10 22:58:07 +07:00
Mohamed Boudra
7f44323686 chore: changelog for 0.1.73 2026-05-10 22:56:55 +07:00
Mohamed Boudra
152b07b599 fix(server): address OpenCode recovery review findings (#904)
* fix(server): pin OpenCode SDK version

* fix(server): tolerate transient OpenCode recovery poll errors

* fix(server): address OpenCode recovery findings 2 and 4

* fix(server): address OpenCode recovery finding 1

* fix(server): address OpenCode recovery finding 3
2026-05-10 23:40:34 +08:00
Mohamed Boudra
84f36d2e20 fix(server): recover OpenCode turns when 1.14.42+ SSE drops early (#902)
OpenCode 1.14.42+ closes the /event SSE stream cleanly right after
server.connected, breaking the entire turn lifecycle: prompts get
queued and run, but Paseo never sees session.idle / message deltas /
tool calls / questions. Every turn either fails with a generic stream
EOF or hangs waiting for events that never arrive.

Switch the post-EOF recovery to the canonical messages REST endpoint
(via the upgraded SDK) and poll incrementally so tool calls and
clarifying questions surface live during the SSE gap. Bound the wait
with a completion cap and a separate liveness cap so silent rejections
fail fast instead of hanging until the cap. Cap session.abort similarly
so explicit cancels land within seconds.

The recovery path is gated on the for-await loop exiting without a
terminal event, so healthy turns never enter it - if upstream restores
SSE delivery, this code becomes dead and is mechanically removable via
the COMPAT(opencodeEofRecovery) and COMPAT(opencodeSlowAbort) tags.

Also tighten the type of projectSettingsRoute on WorktreeSetupCalloutPolicy
so router.navigate accepts it (was widened to string, broke app
typecheck on main).

Refs: getpaseo/paseo#861, anomalyco/opencode#26697,
anomalyco/opencode#26635
2026-05-10 22:43:25 +08:00
João Sousa Andrade
25d4c5023a Harden file explorer symlink handling (#847) 2026-05-10 13:14:50 +00:00
João Sousa Andrade
3f5acfff31 Restrict desktop external URL schemes (#845) 2026-05-10 21:02:47 +08:00
Mohamed Boudra
b9940e285c Fix Codex sub-agent child tool failure status (#899) 2026-05-10 20:27:23 +08:00
nikuscs
9993c6c6c3 fix(app): avoid bottom sheet text input on web (#898) 2026-05-10 12:18:27 +00:00
Mohamed Boudra
3b7971a463 Fix Windows git command console flashing (#897) 2026-05-10 19:46:25 +08:00
nikuscs
d75d2d857d Fix macOS tab jump shortcut conflict (#859) 2026-05-10 11:34:14 +00:00
Mohamed Boudra
cab42985a5 Fix old relay pairing URL TLS compat (#896) 2026-05-10 19:29:56 +08:00
Mohamed Boudra
ef892bd27d Revert "fix(server): wait for opencode completion after EOF"
This reverts commit 2a84b08129.
2026-05-10 18:28:53 +07:00
Link
3014576c4c fix(server): recover completed opencode turns after SSE EOF (#895)
* fix(server): recover completed opencode turns after SSE EOF (#861)

* fix(server): wait for opencode completion after EOF

The original EOF recovery only checked OpenCode storage once after the SSE stream ended. That missed the real failure mode from #861: OpenCode can drop /event while the turn continues behind the scenes, then persist the assistant completion a little later.

Poll the persisted session for the active turn before failing EOF, while still requiring strong completion evidence and ignoring messages that predate the turn. This preserves failure behavior when there is no persisted completion.

Add behavioral coverage for delayed completion after EOF, partial streamed text plus persisted completion without duplication, stale old completions, and the no-evidence failure path.

---------

Co-authored-by: pluto <plutofog@proton.me>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-05-10 11:27:14 +00:00
João Sousa Andrade
b8c77bf0e3 Redact MCP debug request logs (#842) 2026-05-10 09:33:29 +00:00
Mohamed Boudra
17073fe8ff refactor(server): exercise codex features through fake app-server (#887) 2026-05-10 09:27:53 +00:00
Mohamed Boudra
6220b47073 refactor(server): extract codex app-server fake (#873) 2026-05-10 17:01:50 +08:00
Mohamed Boudra
bf7f8f686b refactor(cli): inject local daemon launch runtime (#874)
* refactor(cli): inject local daemon launch runtime

* test(app/e2e): target mobile sidebar toggle state
2026-05-10 08:38:21 +00:00
Mohamed Boudra
93cd4734ce refactor(app): extract workspace terminal lifecycle (#881) 2026-05-10 08:24:19 +00:00
Mohamed Boudra
e4acd6cb7a refactor(server): extract task document persistence (#883)
* refactor(server): extract task document persistence

* test(cli): wait for loop list visibility
2026-05-10 08:23:38 +00:00
Mohamed Boudra
ca11fc667b Refactor daemon connection probe tests (#886) 2026-05-10 08:20:16 +00:00
Mohamed Boudra
36e54a097e Refactor worktree create request parsing (#885) 2026-05-10 08:18:00 +00:00
Mohamed Boudra
ecd3137d34 Extract sidebar callout state (#884) 2026-05-10 08:09:17 +00:00
Mohamed Boudra
f881f9ae32 Refactor relay transport socket tests (#882) 2026-05-10 07:53:54 +00:00
Mohamed Boudra
3f6b84899a Extract worktree setup callout policy (#878) 2026-05-10 07:44:44 +00:00
Mohamed Boudra
5e64a1340c Unslop workspace git watch tests (#880) 2026-05-10 07:42:49 +00:00
Mohamed Boudra
2d0ed004e2 Refactor workspace layout id generation (#876) 2026-05-10 07:34:11 +00:00
Mohamed Boudra
183cda2b66 Extract agent archive projection (#877) 2026-05-10 07:32:14 +00:00
Mohamed Boudra
ed2a97fda8 Extract websocket runtime metrics (#875) 2026-05-10 07:28:48 +00:00
可乐小猫
2fed0f09bb Fix infinite recursion in web crypto randomUUID polyfill (#858)
On web (browser/Electron), expo-crypto's randomUUID() and getRandomValues()
just forward to globalThis.crypto.* (see expo-crypto/src/ExpoCrypto.web.ts).
The previous polyfill installed `g.crypto.randomUUID = () => ExpoCrypto.randomUUID()`,
which on web reads back through the same `globalThis.crypto.randomUUID` it
just installed, recursing until the stack overflows. The same trap exists
for getRandomValues, but it rarely triggers because the native version is
almost always present.

Capture a bound reference to the native getRandomValues *before* installing
the polyfill, and generate UUID v4 in JS from 16 random bytes (RFC 4122
version + variant bits). The fallback path stays on ExpoCrypto.getRandomValues
(non-recursive on native), and ExpoCrypto.randomUUID is no longer used.
2026-05-10 07:07:59 +00:00
Mohamed Boudra
478aa4b70e ci: cancel superseded PR runs to free runner capacity
cancel-in-progress is gated to pull_request events so merge_group and
push runs always complete — only redundant CI from rapid PR pushes
gets cancelled.
2026-05-10 14:12:56 +07:00
Mohamed Boudra
fd74abcdca ci: trigger required checks on merge_group events (#879)
Without merge_group on the workflow, PRs entering the merge queue would
fail because no required check ever reports a status against the
merge_group ref.
2026-05-10 15:07:39 +08:00
Mohamed Boudra
444e265275 refactor(server): inject push notification sender (#872) 2026-05-10 06:49:38 +00:00
Mohamed Boudra
2ee9329663 chore(release): cut 0.1.72 2026-05-10 13:28:02 +07:00
Mohamed Boudra
b30aafc2bd docs(changelog): draft 0.1.72 entry 2026-05-10 13:27:08 +07:00
Mohamed Boudra
90cb20ce79 ci: remove duplicate server-ci workflow (#870)
The matrix in CI / server-tests (ci.yml) covers what Server CI / test was
doing, on Linux and Windows instead of Linux only. Two workflows running
the same test suite on every server-touching PR is wasted runner time.
2026-05-10 06:05:27 +00:00
Mohamed Boudra
bd6feac235 test(codex): regression coverage for app-server JSON-RPC dispatch (#869)
* test(codex): regression test for server-initiated JSON-RPC approval round-trip

Verified this test fails against the pre-#866 classifier shape from #758.

* test(codex): cover all server-initiated request methods in transport
2026-05-10 13:46:10 +08:00
Mohamed Boudra
9f3256a254 refactor(server): inject opencode runtime (#868) 2026-05-10 12:57:19 +08:00
Mohamed Boudra
4165dbe8e9 refactor(server): extract Codex app-server transport (#866) 2026-05-10 12:45:13 +08:00
Mohamed Boudra
15e15b525f refactor(server): extract opencode server manager (#865)
* refactor(server): extract opencode server manager

* test(server): replace opencode server manager mocks
2026-05-10 12:36:00 +08:00
Mohamed Boudra
371c29fecd chore: issue forms, PR template, contributor guidance (#864)
* chore: add issue forms, PR template, contributor guidance

- bug report and feature request issue forms with structured fields for surface, version, provider, logs, screenshots
- issue config disables blank issues and routes casual questions to Discord
- single PR template with explicit verification section and AI-assisted contribution stance
- CONTRIBUTING.md: soften drive-by stance for objective bug fixes, codify expectations on AI-assisted reports and PRs (full logs over summaries, proof of testing over prose)

* chore: drop BDFL framing for one-person team

* chore: tighten CONTRIBUTING, drop duplication
2026-05-10 12:32:22 +08:00
Mohamed Boudra
7d823fd3ec fix: close Pi sessions through runtime owner (#863) 2026-05-10 12:28:29 +08:00
Mohamed Boudra
ab4525db5b refactor(server): extract import session boundary (#854) 2026-05-10 12:08:14 +08:00
Mohamed Boudra
73d27bd5fe refactor(app): centralize tool call presentation (#853)
* refactor(app): centralize tool call presentation

* test(app): inject tool call presentation icons
2026-05-10 12:07:54 +08:00
github-actions[bot]
6db5200890 fix: update lockfile signatures and Nix hash 2026-05-10 03:49:18 +00:00
João Sousa Andrade
9dd5c6e5f3 Patch production dependency advisories (#856)
* Patch production dependency advisories

* ci: harden dependency installs with npm ci --ignore-scripts

- Replace npm install with npm ci --ignore-scripts across all workflows
  to enforce lockfile parity and block dependency postinstall scripts.
- Run npm run postinstall explicitly after install so our root patch
  script still applies (the only legitimate postinstall use in this
  repo); dep lifecycle scripts stay blocked.
- Add lockfile-lint and npm audit signatures to the lint job to catch
  registry-host tampering and verify cryptographic signatures.
- Regenerate package-lock.json to match bumped package.json versions.

* ci: drop --ignore-scripts; rely on npm ci + lockfile-lint + audit signatures

Lavamoat allow-scripts only traverses root deps; it can't see workspace
deps like electron, esbuild, sharp. Without proper monorepo support, an
allowlist would be incomplete and electron's binary download breaks.

Keep the rest of the security stack:
- npm ci (strict lockfile parity)
- lockfile-lint (resolved-host check)
- npm audit signatures (cryptographic verification)

Real script-blocking would need pnpm 10+ migration. Tracking separately.

* ci(nix-build): use PR head SHA so fork checkouts work

The previous `ref: ${{ github.head_ref || github.ref }}` made
actions/checkout fetch a branch name that only exists on the fork,
not origin. Use the PR head SHA instead — origin mirrors PR commits
via refs/pull/N/head, so this works for fork PRs without changing
the push-to-main auto-commit behavior.

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-05-10 11:45:24 +08:00
Mohamed Boudra
407c4ca554 fix(app): enable image drop on new workspace screen (#850)
Wrap the screen in FileDropZone so dragged images are forwarded into the
composer, matching the new-draft screen.
2026-05-09 23:29:00 +08:00
Mohamed Boudra
47f26e99db fix(app): redirect optimistically when archiving worktree from toolbar (#852)
The toolbar archive deferred its redirect until the server confirmed
the archive, so users briefly landed on the "workspace not found" gate
and were then sent to the main checkout's workspace route. Mirror the
sidebar pattern: synchronously router.replace to the project's new
workspace screen up front and let archive reconcile only update the
store.
2026-05-09 23:28:46 +08:00
Mohamed Boudra
263886fc1c refactor(app): move agent tab visibility policy (#851) 2026-05-09 23:28:35 +08:00
João Sousa Andrade
bb5ac5b1d0 Fix relay encryption docs (#849) 2026-05-09 23:16:27 +08:00
Mohamed Boudra
9c1c2cea76 refactor(app): extract workspace archive transaction (#848) 2026-05-09 23:15:27 +08:00
Mohamed Boudra
c0037e56af refactor: share agent state priority (#846) 2026-05-09 22:52:06 +08:00
Mohamed Boudra
4f16056578 refactor(app): unified navigateToAgent (#841)
* refactor(app): unify agent navigation

* fix(app): resolve archived agent navigation
2026-05-09 22:42:03 +08:00
Mohamed Boudra
a4bcc7b169 refactor(app): centralize checkout query keys (#844) 2026-05-09 22:40:26 +08:00
Mohamed Boudra
f17fb77013 refactor: share github remote parsing (#843) 2026-05-09 22:40:05 +08:00
Mohamed Boudra
0079f2a875 test(app): don't crash playwright global-setup when speech creds missing
Fork PRs (e.g. #845) run without OPENAI_API_KEY and without local
speech models, so global-setup.ts:420 threw and blew up the entire
Playwright job before any test could run.

Soften the gate: when neither path is available, warn and run with
dictation/voice disabled. Daemon already honors PASEO_DICTATION_ENABLED=0
and PASEO_VOICE_MODE_ENABLED=0. No spec currently exercises dictation,
so nothing else needs to change; future dictation tests should gate on
PASEO_DICTATION_ENABLED.
2026-05-09 21:37:38 +07:00
Mohamed Boudra
0abcd21cd3 refactor(server): centralize task graph readiness (#840) 2026-05-09 22:06:56 +08:00
Mohamed Boudra
5fb6e0e74d test: defang two flaky tests on main CI
agent.integration "creates an autonomous live turn" — the prompt asked
Claude to use a `Task` tool that doesn't exist in the SDK (Paseo
synthesizes Task entries from sidechain notifications, but Claude can't
invoke it). Haiku non-deterministically refused or complied. Switch to
Bash with run_in_background, matching the deterministic
claude-autonomous-wake-simple.real.e2e variant — same autonomous-wake
mechanism is exercised.

schedule/service "defaults new-agent modeId to provider's unattended
mode" — afterEach raced AgentStorage's background snapshot persist,
producing intermittent ENOTEMPTY rmdirs. Drain pending writes with
agentStorage.flush() before rm.
2026-05-09 20:46:20 +07:00
Mohamed Boudra
d88af28e93 fix(server): suffix-fallback auto-named branches and rotate opencode waterfall
Auto-namer now probes refs/heads/<name> and appends -2, -3, … (up to 50)
when the desired slug is taken, instead of failing with "branch already
exists" and leaving the worktree on its placeholder name.

Structured-generation waterfall replaces opencode/gpt-5-nano with
opencode/minimax-m2.5-free and opencode/nemotron-3-super-free so users
without nano configured fall through to free models they actually have.
2026-05-09 20:44:03 +07:00
Mohamed Boudra
73f35537f9 refactor(server): extract shared task graph utilities (#839)
* refactor(server): extract checkout status projection

* refactor(server): extract shared task graph utilities
2026-05-09 21:28:40 +08:00
Mohamed Boudra
e5deb90986 refactor(server): extract checkout status projection (#838) 2026-05-09 21:28:15 +08:00
Mohamed Boudra
d5ffc51f07 docs(changelog): rewrite 0.1.71 entry in user voice 2026-05-09 20:24:01 +07:00
github-actions[bot]
861917836a fix: update lockfile signatures and Nix hash 2026-05-09 13:17:25 +00:00
Mohamed Boudra
314322d43d chore(release): cut 0.1.71 2026-05-09 20:12:30 +07:00
Mohamed Boudra
cce70d3080 docs(changelog): draft 0.1.71 entry 2026-05-09 20:11:01 +07:00
Mohamed Boudra
43f136deba fix: reload agent rehydrates timeline from disk under new epoch
Reload was a no-op for imported agents whose on-disk session was mutated
externally. The server preserved the in-memory timeline + historyPrimed
flag, so hydrate short-circuited and the epoch never changed. The client
also fetched without a cursor on reload, so the server returned
reset:false and the reducer dropped the new-epoch rows against a stale
cursor — the chat stayed stale until refocus.

Server: reloadAgentSession gains rehydrateFromDisk (used by the refresh
RPC). When set, durable + in-memory timeline are wiped and historyPrimed
is reset, so register mints a new epoch and hydrateTimelineFromProvider
re-streams the freshly read provider history. Voice-mode reloads keep
the existing preserve-timeline semantics.

Client: handleReloadAgent shows a sticky "Reloading agent…" toast,
sends the stale cursor with the fetch so the server returns reset:true,
and only flips to "Reloaded agent" after the response listener has
replaced the visible tail.
2026-05-09 19:52:40 +07:00
Mohamed Boudra
c725352888 feat: import session sheet redesign and metadata-gen filter
Filter persisted metadata-generation sessions out of the import list so
existing on-disk entries stop polluting it for users.

Rename the surface to "Import session" everywhere, swap the bespoke
filter pills for SegmentedControl with provider icons, drop the trailing
provider label on rows (icon is enough), tighten padding, and align the
row icon with the segmented control's left edge.
2026-05-09 19:00:01 +07:00
Mohamed Boudra
d1bda25a0b fix(app): source composer cwd from agent state, not draft hook
The GitHub picker showed "No results found." for any running agent after
the first message was sent. useAgentInputDraft owned a local cwd state
that clear("sent") wiped to "", so the composer read cwd: "" even though
the agent's real cwd was correct on the wire.

Drop cwd ownership from the hook. Composer takes cwd as a prop sourced
by the parent — agentState.cwd for running agents, composerState.workingDir
for drafts, sourceDirectory for setup flows. The hook now persists only
{text, attachments}. Legacy persisted drafts with a cwd field still
hydrate; tagged COMPAT(draft-cwd) for removal after 2026-11-09.

Also moves attachment pills back inside the composer's bordered surface
via a new attachmentSlot prop on MessageInput, and shrinks pill height
from 48 to 32 — the subagents-track tuck previously had attachments
floating between it and the input.

Adds one Playwright spec asserting the draft-create-agent path submits
the workspace cwd.
2026-05-09 18:32:43 +07:00
Mohamed Boudra
d241c24776 fix(app): never auto-focus subagents on workspace open
pickAttentionAgent now skips agents with a parentAgentId. The
workspace auto-focus path was opening subagents as tabs when they
needed attention, breaking the rule that subagents only become tabs
when the user explicitly clicks them.
2026-05-09 18:32:43 +07:00
Mohamed Boudra
ca503c1560 feat(app): grouped project settings with docs links + scrollbar fix (#837)
* feat(app): group project settings into Worktree / Scripts / Metadata

Adds SettingsGroup component (title + info tooltip + trailing slot) and
restructures the project settings screen into three categorized groups
with explanatory tooltips. Metadata prompt labels drop the "prompt"
suffix now that they live under a "Metadata generation" group, and
placeholders read as example instructions a real user would write.
Applies useWebScrollbarStyle to the settings ScrollViews.

* Add ExternalLink component and docs links to project settings
2026-05-09 19:31:19 +08:00
Mohamed Boudra
c34fb26359 feat(app): create empty workspace without a prompt (#834)
* feat(app): create empty workspace from new-workspace composer

Pressing Create with an empty composer (no text, no attachments) on the
new-workspace screen now creates the workspace without an agent and
lands the user on the empty-state, where they can open a terminal or
start an agent on their own. Dispatching submit handler routes to a
named runCreateEmptyWorkspace sibling of runCreateChatAgent so empty
creation is a first-class action, not a hidden branch in the chat path.

* refactor(app): extract empty-workspace helpers into own module

Gives the empty-workspace dispatch a real home, shrinks the test mock
graph from ~20 modules to one.

* test(app): type prompt in new-workspace e2e to exercise chat path

Empty-submission now creates a bare workspace (no agent), so tests that
assert agent-tab/optimistic-draft behavior must type a prompt to take
the chat path.
2026-05-09 18:44:40 +08:00
Mohamed Boudra
030e163fb8 feat: project-level prompts for metadata generation (#836)
* feat(server): project-level prompts for metadata generation

Add `metadataGeneration` config to `paseo.json` so projects can customize the
four LLM-generated metadata strings — agent title, worktree branch name, commit
message, and pull request title+body. Each entry takes an `instructions` string
that gets injected as a `<user-instructions>` block between the default rules
and the load-bearing JSON format contract, with wrapper text saying user
instructions override defaults. Missing/empty/invalid config keeps every prompt
byte-identical to today.

* refactor(server): centralize project metadata prompt building

Three near-identical readers and four gate-then-wrap blocks collapsed
into one buildMetadataPrompt helper. wrapWithUserInstructions tightened
to require a non-empty string; its misleading empty fallback is gone.

* feat(app): edit metadata generation prompts in project settings

Adds four textareas (agent title, branch name, commit message, pull
request) under project settings, modeled on the existing setup/teardown
pattern. Round-trips through paseo.json preserving unknown sibling
fields at both the metadataGeneration and entry level.
2026-05-09 18:36:29 +08:00
Mohamed Boudra
8b09846f98 feat(app): import-agent pill above draft composer (#833)
Surfaces the existing Import-agent sheet directly from the new-agent
draft composer, so users don't have to dig into the workspace header
menu. The pill renders only on draft tabs (gated by the prop being
provided) and reuses the existing WorkspaceImportSheet.
2026-05-09 17:33:14 +08:00
Mohamed Boudra
e3ef88c297 fix(server): delete ephemeral Claude session transcript on close
Claude Code ignores --no-session-persistence outside --print mode, so the
SDK's persistSession=false is silently dropped in stream-json mode and the
metadata/branch-name generator sessions kept showing up as resumable. Sweep
the transcript on close when persistSession=false so internal generator runs
no longer leak into `claude` session listings.
2026-05-09 16:31:52 +07:00
Mohamed Boudra
3eda7dd15b fix(app): subagents track display + archive flicker
- Bucket finished subagents as `done` instead of `attention`; drop
  "needs attention" segment from the section header — subagents are
  transient and don't carry an unread state worth surfacing.
- Gate the subagents selector on the existing pending-archive query so
  optimistic archives survive authoritative agent snapshots that wipe
  the local `archivedAt`. Last subagent archived now cleanly hides the
  whole track via the existing empty-rows guard.
2026-05-09 16:28:09 +07:00
Mohamed Boudra
34df55a10a Fix chat fanout + unify system-injected agent prompts (#830)
* Fix chat everyone fanout

* Unify system-injected agent prompts

Chat mentions, schedule fires, and notifyOnFinish now share one shape:
prompt wrapped in <paseo-system>...</paseo-system>, no user-message
turn recorded, no auto-unarchive. Agents can recognize system-injected
context vs a real user turn.

- Move agent prompt orchestration out of mcp-shared.ts into a neutral
  agent-prompt.ts module (mcp is an interface, not the home).
- Add `unarchive` and `recordUserMessage` flags to sendPromptToAgent.
- Add formatSystemNotificationPrompt helper.
- Schedule fires now identify themselves: "Schedule \"<name>\" fired
  (id=..., run=...)." Plumb runId through runner so the paseo.schedule-run
  label matches the actual run record.
- Replace chat-mention validateChatMentionFanout + duplicate
  resolveChatMentionTargetAgentIds with single prepareChatMentionFanout
  that owns expansion + cap + poster snapshot.
2026-05-09 17:10:22 +08:00
Mohamed Boudra
0229ef94f1 fix(app): submit typed path from iOS project picker (#829) (#831)
Wire onSubmitEditing on the picker TextInput so the iOS return key
submits a typed daemon-side path through the existing
open_project_request flow. Prepend the typed path to options when not
already present so it's tappable and participates in keyboard nav.
2026-05-09 16:46:49 +08:00
Mohamed Boudra
5b884aee0c Import existing Claude/Codex/OpenCode sessions (#766)
* Restrict provider import fan-out to enabled, non-derived allowlist

Move provider eligibility from a session-level post-filter to the agent
manager's fan-out so disabled providers are never spawned (fixes the
Gemini-disabled hang) and derived providers (like a custom Z.AI extending
Claude) don't duplicate their base provider's sessions.

Add an IMPORTABLE_PROVIDERS allowlist (claude, codex, opencode); ACP-based
providers are excluded from import discovery entirely.

* Filter Codex persisted threads by cwd before hydrating turns

Codex's listPersistedAgents previously called thread/read includeTurns on
every thread returned by thread/list, even when the import sheet was
scoped to one cwd. With many recent threads outside the scope, that fan
of reads dominated the import-sheet open latency.

Add an optional cwd hint to ListPersistedAgentsOptions, thread it through
the agent manager, and have Codex pre-filter the cheap thread/list rows by
cwd before scheduling the per-thread hydration. Widen the thread/list
window when filtering since most rows will be from other cwds. Other
providers ignore the hint.

* Fan out import session fetch per enabled provider

Sheet now reads the providers snapshot already warmed by the workspace
screen, filters to enabled importable providers (claude/codex/opencode),
and issues one fetchRecentProviderSessions request per provider via
useQueries with limit 15. Results are merged, deduped by handle, and
sorted by last activity.

Falls back to a single unfiltered request when the daemon does not
expose a provider snapshot (older servers). Per-provider failures show
inline so a single broken provider does not hide the rest.

* Add provider filter badges to import sheet

Show "All" plus one badge per enabled importable provider above the
session list when more than one is available. Single-select narrows the
already-merged list — no refetch. Each row now shows the provider's
icon next to its label so multiple providers stay visually distinct.

Selection resets when the sheet closes.

* Distinguish already-imported empty state in import sheet

Track how many descriptors were dropped because their handle was
already imported, surface the count back to the client as an optional
`filteredAlreadyImportedCount`, and switch the sheet copy to
"All recent sessions are already imported." when the merged list is
empty solely due to that filter.

* Cover already-imported provider session filtering

* List persisted OpenCode sessions for import

* Revalidate import sessions when sheet reopens

* Add agent persistSession plumbing

* Drop redundant visibility-transition refetch and test boilerplate

The import sheet's queries are gated on `enabled: visible`, and react-query
already refetches stale data when `enabled` flips on, so the manual
`wasVisibleRef` + `invalidateQueries` effect was redundant. Collapse the
two `selectedProvider` reset effects into one.

Drop `expect(...).toBeTruthy()` after `findByText` / `getByText` — those
helpers throw on miss, so the assertion was noise.

Drop `| null` from `buildOpenCodePersistedAgentDescriptor`'s return type
(it never returns null) and the dead `Boolean(descriptor)` filter at the
caller.

* Add provider-opaque session import path

Introduces fetchRecentProviderSessions on the daemon client plus an
importAgent overload accepting providerId/providerHandleId, and a
projection helper that renders persisted descriptors as provider-opaque
recent-session payloads with prompt previews.

* Unslop the import-session feature

Centralizes the importable-providers list, normalizes import_agent_request
shape at one boundary, and removes the per-provider fan-out fallback in
the import sheet. Old daemons without features.providersSnapshot now show
"Update host" instead of being served a degraded path.

Replaces internal-helper unit tests on the OpenCode and Codex providers
with boundary tests through listPersistedAgents; makes storageRoot
injectable on the OpenCode provider so the boundary test can target a
temp directory.

Splits the CLAUDE.md back-compat rule into two contracts: protocol stays
backward-compatible (always); features may require new daemons (no
fallback paths). Tags the two new compat shims with COMPAT(name) and
target removal dates.

* Extract importable-session listing into its own module

Move listImportableProviderSessions out of session.ts and drop the
unused generic listPersistedAgents from AgentManager.
2026-05-09 16:36:06 +08:00
João Sousa Andrade
8be2692b8a Protect local state file permissions (#825) 2026-05-09 16:28:23 +08:00
Mohamed Boudra
103601705d feat(app): focus attention-needing agent tab on workspace navigation (#828)
When navigating to a workspace, auto-open the tab for the agent that
most urgently needs attention (permission > error > finished, oldest
first). Adds pickAttentionAgent + wires it into navigateToWorkspace.
2026-05-09 16:07:14 +08:00
Mohamed Boudra
68ceac492c test(claude): drop flaky token-echo assertion in autonomous-wake test
Relied on Claude echoing AUTONOMOUS_WAKE_<token> verbatim, which is model-compliance, not the wake mechanism. The structural turn_started + turn_completed pair on the post-foreground subscribe stream is uniquely emitted by startAutonomousTurn() and proves the autonomous live turn fired.
2026-05-09 14:53:47 +07:00
Mohamed Boudra
ed07452c80 feat(checkout): merge PR action with real-GitHub e2e test (#814)
* feat(checkout): merge PR action with real-GitHub e2e test

Adds a `checkoutPrMerge` server RPC backed by `gh pr merge` (squash, merge,
or rebase) and surfaces three new actions in the git-actions menu —
"Squash and merge", "Create a merge commit", "Rebase and merge" — with
squash taking the primary slot when a PR is open and mergeable. Also
renames the existing local merge action to "Merge locally" to disambiguate
it from the new GitHub-side merge.

Adds an end-to-end test that creates a real temporary GitHub repo, opens a
PR via the daemon, polls until mergeable, calls `checkoutPrMerge`, and
verifies the merge landed on `main` — no mocks. The test pre-flights the
`delete_repo` scope so it skips cleanly on tokens that can't tear down,
and fails loud if cleanup ever errors so we don't leak repos.

PR status gains an optional `mergeable` field (`MERGEABLE | CONFLICTING |
UNKNOWN`) populated from `gh pr view --json mergeable`. The schema is
backward compatible — old payloads without the field still parse and
default to `UNKNOWN` via `z.catch`.

* Update PR status snapshot expectations

* refactor: collect git/checkout/PR feature into packages/app/src/git/

Move 21 git/checkout/PR files out of components/, hooks/, stores/, screens/,
utils/ flat-peer slop into a single packages/app/src/git/ module with
shrunk filenames (git-diff-pane.tsx -> diff-pane.tsx, use-git-actions.ts ->
use-actions.ts, etc).

Dedupe: drop the duplicate useGitActionHandlers from diff-pane.tsx — the
shared useGitActions hook is the single source. Drop the
buildGitActionsForPane wrapper. Collapse the merge-PR triple fan-out
(mergePrSquash/Merge/RebaseStatus) into one record keyed by
CheckoutPrMergeMethod. Streamline server-side single-use helpers
(dispatchStash/PullRequestMessage adapters, GitHubPullRequestMergeError
wrapper, legacy worktree test helpers).

Schema-additive only: messages.ts exports CheckoutPrMergeMethod and
PullRequestMergeable types; daemon-client.ts uses the typed alias.
Backward-compatible.

Net delta: 43 files, +219/-1212.

* fix(app): use GitHub icon for all merge-PR menu actions

* test: namespace temp GitHub repos under `paseotmp-` and centralize naming

Old prefixes (`paseo-checkout-ship-`, `paseo-e2e-`, `paseo-checkout-pr-merge-`)
collided with the `paseo-*` namespace of real repos, making bulk cleanup
unsafe. Switch every test that creates real GitHub repos to a single
unmistakable `paseotmp-` prefix, owned by one shared helper per test
boundary so no caller can name a repo outside the namespace.

- New `packages/server/src/server/test-utils/temp-github-repo.ts` exports
  `TEMP_GITHUB_REPO_PREFIX` + `createTempGithubRepoName(category)`; both
  server e2e tests (checkout-ship, checkout-pr-merge) consume it.
- `packages/app/e2e/helpers/github-fixtures.ts` locks the prefix internally
  and now requires callers to pass a `category` instead of an arbitrary
  `prefix`. Updated pr-pane and composer-attachments specs accordingly.
2026-05-09 15:39:26 +08:00
Mohamed Boudra
c097678cdf Surface paseo subagents in a collapsible section above the composer (#532)
* Surface paseo subagents in a collapsible section above the composer

Subagents no longer auto-open as tabs. Each parent agent's pane shows
a compact tab attached to the top of the composer, listing its direct
children with name and status; clicking a row opens or focuses the
subagent's tab. The section is provider-neutral so Claude Code Task
and Codex subagent sources can feed the same UI later. Reconciliation
now separates pruning (`activeAgentIds`) from auto-opening
(`autoOpenAgentIds`) so manually opened subagent tabs survive.

* Tighten subagents composer code from review

Collapse duplicate helper in workspace reconciliation, swap a hand-rolled
shallow comparator for fast-deep-equal (matching repo convention), drop
an unused React default import and a single-use formatCount helper, and
extract a shared PaneOverrides type in the agent-panel test.

* Fix oxlint failures in subagents code

Switch type aliases to interfaces, hoist inline JSX styles/handlers in
SubagentsSection to satisfy react-perf rules, and lift the test
makeAgent helpers onto AGENT_DEFAULTS so their cyclomatic complexity
drops below the configured cap.

* Add missing unistyles mock exports after rebase

After rebasing onto main, SubagentsSection now imports both
useUnistyles and (transitively, via WorkspaceTabIcon) withUnistyles.
Two test mocks needed updating to expose them.

Test drift fix: the implementation contract is correct; the local
react-native-unistyles mocks were stale.

* Tuck subagents tab under the composer

Subagents tab now grows visually from beneath the composer's rounded
top edge: the surface overlaps the composer by the corner radius so
the rounded curve nests into the tab. Composer's top padding is
removed so the tab sits flush against the input box with no
click-dead gutter. The tab is full-width in both states, the header
label drops the medium weight to follow design-system §3 (content
text inside a surface is normal), and hover covers the entire
visible tab when collapsed but stays scoped to the header row when
expanded.

* Decouple subagent close-tab from archive

Closing a subagent's tab no longer archives the agent — the tab is
removed from the per-client layout, the agent stays in the parent's
track until explicitly archived. Track rows gain an archive button (X)
to make the lifecycle gesture explicit. AgentManager.archiveAgent now
cascades to children carrying the paseo.parent-agent-id label so
subagent fleets don't outlive their orchestrator.

Trade-offs documented in docs/agent-lifecycle.md, including the known
limitation that handoff agents launched via the same MCP path get
cascade-archived alongside true subagents until a richer relation
model lands.

* Stabilize subagent archive button hover

Apply the sidebar workspace row pattern to fix two issues with the
new archive button: the row no longer drops hover when the pointer
moves onto the button, and the button slot reserves layout space
when invisible so adding/removing it doesn't shift the row.

The wrapper View tracks hover via onPointerEnter/onPointerLeave
(workspace rows do the same), and the slot stays mounted with
opacity 0 + pointerEvents="none" when the button is hidden. Also
swap the X icon for the Archive icon to match the gesture's intent.

* Match design language for subagent archive button

Hover state now changes the icon color from foregroundMuted to
foreground, matching the project-row trailing-action pattern in
sidebar-workspace-list.tsx. Drops the surface3 background hover
that didn't match the design language.

Adds a tooltip ("Archive subagent") on the trigger using the
canonical Tooltip + TooltipTrigger asChild pattern. Disabled when
the slot is hidden so it never appears unbidden.

* Slot subagents into one module + harden cascade

Reshape the subagents-in-composer feature so it lives behind one
module and integrates at a few explicit sites instead of being
smeared across generic files.

- Extract `PARENT_AGENT_ID_LABEL` to `packages/server/src/shared/agent-labels.ts`. Sweeps 16 literal sites across server, client, CLI, and tests.
- `packages/app/src/subagents/` is now a deep module: `index.ts` is the only React entry (`SubagentsSection`, `useArchiveSubagent`, `useSubagentsForParent`, `selectSubagentsForParent`, `resolveCloseAgentTabPolicy`, `shouldAutoOpenAgentTab`); `policies.ts` is a second designed entry for non-RN data consumers. Internal files renamed (`section.tsx`, `select.ts`, `close-tab-policy.ts`, `auto-open-tab-policy.ts`, `use-archive-subagent.ts`).
- Move the 22-line subagent archive flow out of `agent-panel.tsx` into `useArchiveSubagent`. Agent-panel becomes a one-line consumer.
- Move the close-tab subagent branch out of `workspace-screen.tsx` into `resolveCloseAgentTabPolicy` (discriminated union).
- Move the auto-open exclusion rule out of `workspace-agent-visibility.ts` into `shouldAutoOpenAgentTab`.
- Theme chevrons in `section.tsx` via `withUnistyles`; drop the forbidden `useUnistyles()` call from the hot path.

Server-side cascade hardening:

- Single write path: extract `markRecordArchived` and route both `archiveAgent` and the off-memory cascade branch through it. Off-memory cascaded children now notify subscribers (previously silent).
- Cascade no longer swallows child failures with `try/catch warn`. Failures propagate.
- Cascade test extended from 1 weak assertion to 5 contract tests: full archive shape (archivedAt, normalized lastStatus, requiresAttention=false), running child runtime stop, off-memory branch, subscriber notification for in-memory and off-memory children, partial-failure surfacing.

Net diff vs the start of this PR: -1037 / +444 lines. The feature now slots into the repo at four explicit sites: `agent-panel.tsx` (3 imports + a hook + the section), `workspace-screen.tsx` (one policy import), `workspace-agent-visibility.ts` (one predicate import), and `agent-manager.ts` (the cascade plus the shared label constant).
2026-05-09 15:39:13 +08:00
Mohamed Boudra
0364e8dc5e fix(app): preventDefault in capture phase for MarkdownLink anchor
Pressable's click handler calls stopPropagation, so the bubble-phase
preventDefault on the wrapping <a> never fired and the Electron view
navigated to the href in addition to the existing helper opening it
in the OS browser. Move preventDefault to onClickCapture (and
onAuxClickCapture for middle-click) so it always runs first. The
existing Pressable.onPress helper remains the only path that actually
opens links.
2026-05-09 12:44:07 +07:00
Mohamed Boudra
650d3148e9 feat(desktop): rich context menu — copy link, image, spellcheck
Replaces the hardcoded Copy/Paste/Select All menu with one driven by
ContextMenuParams: link items (Open in Browser, Copy Link Address),
image items (Copy Image, Save Image As), spellcheck suggestions, and
proper Cut/Copy/Paste in editable inputs. Both the main window and
the browser webview menus go through the shared helper.

MarkdownLink (web branch) now wraps its Pressable+Text in a real
<a href> with `display: contents` so Electron exposes linkURL on
right-click. Native branch is untouched.
2026-05-09 12:24:08 +07:00
Mohamed Boudra
511580a47e feat(mcp): inherit target's unattended mode when caller is unattended cross-provider
create_agent previously threw on cross-provider inheritance unless an explicit
mode was passed. In practice, sub-agents launched without a mode should run
unattended — keying permissions per sub-agent rarely makes sense. When the
caller is in an unattended mode (Claude bypassPermissions, Codex/OpenCode
full-access, Copilot autopilot) and no mode is requested, default the child
to the target provider's own unattended mode. Non-unattended callers still
require an explicit cross-provider mode, and explicit modes always win.
2026-05-09 12:06:42 +07:00
ezra
0be9764194 feat(codex): archive native Codex thread when archiving agent (#827)
When a Codex-backed agent is archived from Paseo, call Codex's
thread/archive API so the session is also marked archived in the
Codex app. This keeps both UIs in sync.

- Add optional archiveNativeSession() to AgentClient interface
- Implement for Codex provider via thread/archive JSON-RPC call
- Call best-effort in both archiveAgent() and archiveSnapshot() paths
- Failures are logged as warnings and do not block the archive flow

Closes #822
2026-05-09 12:37:09 +08:00
Mohamed Boudra
c40e1f03db docs: deep audit pass — fix drift across every doc, rebase coding-standards on /unslop
Each doc verified against current code; stale claims fixed in place.

- architecture: handshake/binary-frame/route/module-table corrections
- data-model: atomic-write claim narrowed, missing daemon files/fields added
- development: db:query removed (SQLite/Drizzle gone), tmux→concurrently+portless, PASEO_HOME split
- unistyles: withUnistyles(Icon) is the dominant pattern; new Animated.View+dynamic-styles iOS gotcha
- SECURITY: bearer-token auth, DNS-rebinding allowlist semantics, ephemeral phone keypair, wire format
- glossary: Project-checkout rename has shipped
- providers: claude-acp not built-in, Pi/mock, async isCommandAvailable, interface drift
- coding-standards: rewritten as compressed /unslop adaptation
- product/release/testing/custom-providers/android/mobile-testing/ad-hoc-daemon-testing/file-icons/design: smaller corrections
2026-05-09 11:35:26 +07:00
Mohamed Boudra
3a5d999006 docs: refresh CLAUDE.md doc index, fold design.md into design system
- Make `docs/` the explicit source of truth: "the docs"/"check the docs" mean this directory, not the web.
- List all 16 docs in CLAUDE.md (was 8); group by purpose so unistyles, glossary, data-model, providers, etc. are discoverable.
- Delete the old design.md (designing-features prose) and rename design-system.md → design.md so design tokens own the name.
2026-05-09 11:21:58 +07:00
Mohamed Boudra
832a2031bb fix(app): hoist QueryProvider above PortalProvider so portaled sheets see QueryClient
@gorhom/portal renders children at the host's location in the tree, so
sheets portaled to the root host lost QueryClient context (crash:
"No QueryClient set" when opening provider diagnostic sheet). Reorder
RootProviders and document the invariant: PortalProvider stays innermost.
2026-05-09 11:04:38 +07:00
Mohamed Boudra
1042270298 ci(nix): merge Fix Nix hash into Nix Build, commit only after green build
Single workflow now: update lockfile + hash, build, then commit the fix on
main only if the build passed. Eliminates the structural first-failure where
Nix Build always raced the autofixer and lost.
2026-05-09 10:26:36 +07:00
Mohamed Boudra
436bab0c8a fix(fastlane): populate whatsNew on new App Store version before review
submit_for_review failed with "appStoreVersions ... is not in valid
state - missing required attribute 'whatsNew'". Apple requires a
non-empty "What's New" string on every new version. With skip_metadata,
deliver never sets it, so we populate it ourselves via spaceship for
every existing localization on the edit version.
2026-05-08 21:47:46 +07:00
Mohamed Boudra
4792cba794 fix(fastlane): create editable App Store version before deliver
submit_review reached the deliver step but failed with "could not find
an editable version for 'IOS'". With skip_metadata + skip_screenshots,
deliver does not auto-create the "Prepare for Submission" entry on App
Store Connect, so submitting fails when the marketing version has not
been created yet.

Use Spaceship's ensure_version! to create or reuse the editable App
Store version for the build's marketing version, and pass app_version
to deliver so it targets the right entry.
2026-05-08 21:42:24 +07:00
Mohamed Boudra
39099a54b8 fix(fastlane): filter Build.all by build_number, not version
latest_testflight_build_number returns the build number (e.g. "2"), not
the marketing version. Previous fix used the version: kwarg which maps
to filter[preReleaseVersion.version] — that's the marketing version
("0.1.70") and never matched the integer build number, so the query
returned no builds and the lane spun on "processing state: unknown"
until timeout.

Apple's API names are confusing: filter[version] is the build number,
filter[preReleaseVersion.version] is the marketing version. The
build_number: kwarg in spaceship maps to filter[version], which is
what we want.
2026-05-08 21:37:45 +07:00
Mohamed Boudra
c9d842bd33 chore(gitignore): ignore app.json 2026-05-08 21:34:15 +07:00
Mohamed Boudra
ee4d72c706 fix(fastlane): use version: kwarg for Spaceship Build.all
The submit_review lane was crashing with "unknown keyword: :filter"
because Spaceship::ConnectAPI::Build.all doesn't accept a filter hash;
it takes specific kwargs (app_id, version, build_number, etc.) and
constructs the JSON:API filter internally.

Also drop includes: "preReleaseVersion" — it's part of ESSENTIAL_INCLUDES
by default.

Add resubmit-ios-review.yml workflow so we can re-run only the
fastlane submit_review lane (no rebuild) against an existing
TestFlight build via workflow_dispatch.
2026-05-08 21:25:41 +07:00
github-actions[bot]
53e477bc00 fix: update lockfile signatures and Nix hash 2026-05-08 13:34:00 +00:00
Mohamed Boudra
58fc2bb434 chore(release): cut 0.1.70 2026-05-08 20:32:20 +07:00
Mohamed Boudra
6e75c72492 chore: refresh lockfile dev markers, document release gotcha
`release:prepare` runs `npm install` which churns optional-dep `dev`
markers in package-lock.json. That dirties the tree before the
`npm version` step in `version:all:*`, and the pre-commit format hook
rejects a lockfile-only commit because oxfmt internally skips
package-lock.json. Document the run-format/lint/typecheck-first
guardrail in docs/release.md.
2026-05-08 20:31:26 +07:00
Mohamed Boudra
3ed5567c8a docs(changelog): promote 0.1.70-beta.1 to 0.1.70 2026-05-08 20:21:47 +07:00
Mohamed Boudra
ad08868778 Fix Claude binary resolution for replace-mode command override
isAvailable() honored runtimeSettings.command.mode === "replace" but
resolveClaudeBinary() only looked for `claude` on PATH. Users with a
custom claude wrapper and no `claude` itself on PATH passed the
availability check and then failed at session creation with "Claude
binary not found".

resolveClaudeBinary now mirrors isAvailable: if the runtime command is
in replace mode, look up the override binary first.
2026-05-08 20:13:27 +07:00
Mohamed Boudra
5b6e974248 Fix terminal PTY teardown races 2026-05-08 20:02:52 +07:00
Mohamed Boudra
04b04fe5ee Fix terminal subscription snapshot ordering 2026-05-08 19:29:15 +07:00
Mohamed Boudra
9d5a47b1c3 Fix Claude integration query cleanup 2026-05-08 19:17:25 +07:00
Mohamed Boudra
5c90449707 Centralize Claude SDK launch path 2026-05-08 18:47:56 +07:00
Mohamed Boudra
e490bf1dab fix(server-tests): unblock Windows CI for Claude SDK + .exe launch test
- spawn.launch-regression: insert `--` between `node -e <body>` and the
  user JSON args. Without it Node treats `--config` as a CLI option and
  exits with code 9 ("bad option"). The `--` stops Node's flag parsing
  so userArgs land in process.argv intact.
- claude-agent.integration + claude-sdk-behavior: inject
  spawnClaudeCodeProcess that routes the SDK's launch through our
  spawnProcess (shell: false). On Windows the SDK's default spawn fails
  with EINVAL when pathToClaudeCodeExecutable resolves to a `.cmd` shim
  (CVE-2024-27980). This mirrors what claude-agent.ts does in production.
- 3 cleanup sites (event-stream, integration, sdk-behavior): swallow
  EBUSY/ENOTEMPTY/EPERM around rmSync. The maxRetries window doesn't
  reliably win the Windows cwd-lock race after Claude exits; the OS will
  reap the tmpdir.
2026-05-08 17:43:15 +07:00
Mohamed Boudra
b131876e69 test(server): cover .exe/.cmd/.bat end-to-end launch on Windows
Add three end-to-end tests in spawn.launch-regression.test.ts that exercise
findExecutable -> spawnProcess for each Windows-supported extension. Each
test creates a uniquely-named binary in a tempdir, prepends it to PATH,
verifies findExecutable returns the fixture path, then launches via
spawnProcess and asserts an exact JSON-arg roundtrip plus stdout/exit code.

The previous "does not detect a PowerShell shim from PATH" test is removed:
its expectation was satisfied via PATH-prepend (not isolation), so global
claude.CMD on CI made it return non-null. The supported-extension contract
matches which's hardcoded fallback (.EXE;.CMD;.BAT;.COM); .ps1 is not
supported by spawnProcess and not surfaced by which without explicit PATHEXT.

Also add maxRetries: 5 / retryDelay: 100 to rmSync cleanups in three Claude
integration tests to absorb the brief Windows cwd lock after Claude exits.
2026-05-08 17:24:52 +07:00
Mohamed Boudra
b7d2e6d3ec Revert "ci: gate Claude Code install to Linux server-tests"
This reverts commit 09ed46b997.
2026-05-08 16:28:49 +07:00
Mohamed Boudra
09ed46b997 ci: gate Claude Code install to Linux server-tests
Installing claude-code globally on the Windows server-tests runner
exposed 12 pre-existing Windows-specific test failures that were
silently skipped before (rmdir EBUSY during cleanup, .CMD spawn
EINVAL via the SDK, plus the launch-regression test that asserts
claude is *not* on PATH).

The Linux e2e tests (model-catalog, live-preferences, models) are
the only ones that needed the binary to flip isAvailable() true;
none of those run on Windows. Skip the install on Windows so those
Windows compatibility gaps stay surfaced separately and don't block
the SDK upgrade.
2026-05-08 16:26:43 +07:00
Mohamed Boudra
a4d365c8f2 fix server-ci: install only claude-code, set SDK path in contract test
Two regressions from the previous CI install:

- Installing opencode-ai globally in server-ci/server-tests caused
  opencode-agent.test.ts (gated on \`hasOpenCode\`) to start running
  for the first time and surface real but unrelated test breakage.
  Trim the install to claude-code, which is all that's needed for the
  Claude e2e tests that motivated the change.

- claude-agent.integration.test.ts's supportedModels test calls the
  SDK's \`query()\` directly (bypassing ClaudeAgentClient) and didn't
  pass \`pathToClaudeCodeExecutable\`. The 0.2.71 SDK had a bundled
  cli.js fallback; 0.2.133 doesn't, so on Ubuntu CI it tries the
  per-platform binary path (\`...claude-agent-sdk-linux-x64-musl/claude\`)
  which isn't installed via the \`claude-code\` global. Resolve the
  binary explicitly in the test and pass it as the SDK option.
2026-05-08 16:14:28 +07:00
Mohamed Boudra
13538dd710 ci: install claude-code globally in server-tests
server-tests in ci.yml and Server CI both need claude on PATH for the
e2e tests that probe model catalogs and live preferences. cli-tests
already installs the trio (claude-code, codex, opencode-ai); server
tests now match.
2026-05-08 16:05:30 +07:00
Mohamed Boudra
e60350be08 refactor(claude-agent): inject binary resolver via client constructor
The session previously called resolveClaudeBinary() directly in
buildOptions(), with no seam for tests. CI ran without claude on PATH,
so every test that exercised buildOptions hit the production throw.

Apply the ports-and-adapters pattern at the client boundary (the same
place tests already configure queryFactory):

- ClaudeAgentClient takes optional resolveBinary, defaults to
  resolveClaudeBinary which throws "Claude binary not found..." when
  the executable is missing — preserving production fail-fast.
- Client passes resolveBinary through to ClaudeAgentSession options.
- buildOptions calls this.resolveBinary() instead of importing the
  free function directly.
- Tests construct the public client with a stub resolver. No
  __claudeAgentInternals export, no reaching into the session.

User-facing fail-fast still lives where it always did — AgentManager
gates start/resume/draft flows on client.isAvailable(). The throw in
resolveClaudeBinary is defense-in-depth that mirrors Codex.
2026-05-08 15:55:14 +07:00
Mohamed Boudra
b0c36f2bab test: Claude isAvailable now reflects PATH binary, no SDK fallback 2026-05-08 15:28:46 +07:00
Mohamed Boudra
d708f099be fix(claude-agent): don't fail-fast in buildOptions
resolveClaudeBinary threw eagerly inside buildOptions, breaking unit tests
that mock the SDK at the module level (env, redesign, interrupt-restart-
regression suites). The user-facing fail-fast already lives in isAvailable();
in buildOptions, fall back to omitting pathToClaudeCodeExecutable so the SDK
surfaces its own error if the binary truly is missing.
2026-05-08 15:25:57 +07:00
Mohamed Boudra
cb2fe91998 fix CI fallout from claude-agent-sdk 0.2.133 upgrade
- mcp-server tests: MCP SDK 1.29 renamed RegisteredTool.callback → handler
- ci: install @anthropic-ai/claude-code globally for cli-tests, matching
  codex/opencode now that bundled fallback is gone
2026-05-08 15:18:24 +07:00
github-actions[bot]
d7820b7a84 fix: update lockfile signatures and Nix hash 2026-05-08 08:08:58 +00:00
Mohamed Boudra
fb0fbb79f6 upgrade claude-agent-sdk to 0.2.133, drop bundled binary
Bumps @anthropic-ai/claude-agent-sdk from ^0.2.71 to ^0.2.133. The new SDK
hardens handleControlRequest against post-close transport writes (v0.2.94+),
which fixes the unhandledRejection that crashed the daemon when the SDK fired
a fire-and-forget control_request after we'd torn down the transport.

The SDK now ships per-platform Claude Code binaries via optionalDependencies
(~210 MB each). Paseo requires user-installed `claude` on PATH instead — same
posture as Codex/OpenCode:

- after-pack now prunes @anthropic-ai/claude-agent-sdk-* from the Electron
  bundle, matching the existing per-platform pruning for onnxruntime, sharp,
  and node-pty.
- claude-agent.ts gains resolveClaudeBinary() (mirrors resolveCodexBinary):
  throws with install instructions when `claude` is missing, instead of
  silently falling back to the bundled binary.
- isAvailable() now checks isCommandAvailable("claude") instead of always
  returning true.
2026-05-08 15:07:40 +07:00
Mohamed Boudra
c97c000d9c paseo-epic: lock requirements early and gate on them
Adds a pre-start preferences step (onboards users with no
orchestration-preferences.json), a capture-intent step that runs
even under --autopilot, an immutable Requirements block in the plan
that includes acceptance criteria, and a final lightweight check
that audits every requirement before deliver. Drops --no-grill.
2026-05-08 11:46:45 +07:00
Mohamed Boudra
c4e4a28bc0 Run server tests on Linux+Windows matrix (#809)
* Run server tests on Linux+Windows matrix

Replace the hand-curated Windows server test allow-list with a full-suite matrix run.\nBoth Ubuntu and Windows now run the same server test command with shared setup and secrets.

* Add isPlatform helper and gate Windows-hostile server tests

- Add a shared server test isPlatform helper.

- Migrate existing Windows-gated spawn, worktree, executable, and worktree-core tests to the helper.

- Gate Windows-hostile symlink, macOS path-normalization, and POSIX shell setup tests with skipIf.

* Replace POSIX shell calls in test fixtures with Node primitives

- Replaced test fixture mkdir/echo shell setup with fs mkdirSync/writeFileSync calls in touched server tests.

- Replaced git shell strings with execFileSync/spawnSync argv calls across checkout, worktree, MCP, script, and workspace git fixtures.

- Gated the directory suggestion symlink escape tests on Windows because those fixtures require POSIX symlink behavior.

* Fix Windows server-test failures and split POSIX-only suites into sibling files

- Fix Windows path/cwd assertions in terminal, session/workspace, git-service, checkout-git, MCP, logger, spawn, and registry bootstrap tests.\n- Keep terminal tests runnable on Windows by canonicalizing temp cwd fixtures and ensuring a Windows shell fallback.\n- Gate POSIX-only shell, signal, Unix socket, and git-worktree reuse fixtures that need dedicated Windows coverage later.

* Move POSIX-only test blocks into sibling .posix.test.ts files

- terminal.test.ts: moved PTY/bash interaction blocks into terminal.posix.test.ts.

- worktree.test.ts: moved git-worktree and teardown shell blocks into worktree.posix.test.ts.

- worktree-bootstrap.test.ts: moved setup shell and terminal-backed service blocks into worktree-bootstrap.posix.test.ts.

- worktree-core.test.ts: moved the POSIX-only worktree-core suite into worktree-core.posix.test.ts and removed the empty original.

- provider-availability.test.ts and file-explorer/service.test.ts: moved POSIX PATH/symlink blocks into sibling suites.

* Fix Windows 8.3 short-name, EBUSY cleanup, and node-pty shell-path failures in server tests

- Normalize temp home directories in directory suggestion assertions to avoid Windows short-name mismatches.

- Use Windows-valid terminal shells/cwds in terminal fixtures and wait for terminal-manager PTYs before cleanup.

- Replace hardcoded POSIX paths in workspace-git/MCP/loop fixtures with platform-resolved paths or command files.

- Gate the two explicitly Linux-only workspace-git watcher tests.

* Replace hardcoded POSIX paths with portable Node path constructions in server tests

- checkout-git.test.ts and worktree-session.test.ts: compare Windows temp paths with realpathSync.native to avoid 8.3 short-name mismatches.

- directory-suggestions.test.ts: canonicalize result and expected paths with realpathSync.native.

- session.workspaces.test.ts: replace literal /tmp and /Users fixtures with path.resolve/path.join constructions.

- workspace-git-service.primitive.test.ts, loop-service.test.ts, and mcp-server.test.ts: use canonical repo/temp paths and shell-safe relative verify commands.

* Fix loop-service verify-check shell and workspace-git-service path-separator on Windows

- Run the loop-service verify script through the current Node executable with a relative script path.

- Normalize the workspace-git-service expected repo cwd to forward slashes for the listWorktrees assertion.

* Fix loop-service worker PTY spawn on Windows

- Canonicalize the loop-service temporary root and workspace with realpathSync.native before worker agents use the path as cwd.

* Gate loop-service real-worker-PTY test on Windows

- Skip the real worker PTY loop test on Windows after ConPTY path resolution still fails with node-pty error 267.\n- Keep the test running on POSIX so the loop behavior remains covered.

* Stub getMetricsSnapshot in test mocks to suppress async-leak uncaught exceptions on Windows

- Add a no-op AgentManager metrics snapshot to the WebSocket notification test server stub.

* Fix terminal-manager Windows-path test to avoid PTY spawn into nonexistent dir

- Use an existing temporary cwd for the createTerminal absolute-path validation assertion so node-pty does not spawn into a missing Windows directory.
2026-05-08 10:57:22 +08:00
Mohamed Boudra
39e461b872 perf(website): render hero text immediately 2026-05-07 23:38:32 +07:00
Mohamed Boudra
734e15e5a3 perf(website): cache GitHub release metadata in KV 2026-05-07 23:22:48 +07:00
Mohamed Boudra
90bf6571d6 Revert "perf(website): avoid blocking root render on GitHub"
This reverts commit 89a500cd3e.
2026-05-07 23:10:26 +07:00
Mohamed Boudra
90fe71bd54 chore: ignore generated TanStack files 2026-05-07 23:06:48 +07:00
Mohamed Boudra
89a500cd3e perf(website): avoid blocking root render on GitHub 2026-05-07 23:06:27 +07:00
Mohamed Boudra
a1ac402154 docs(website): add provider pages and refresh docs 2026-05-07 22:52:26 +07:00
Mohamed Boudra
f7eac82593 fix(website): smooth docs navigation and improve lighthouse 2026-05-07 22:42:39 +07:00
github-actions[bot]
4a9c2450b7 fix: update lockfile signatures and Nix hash 2026-05-07 15:19:24 +00:00
Mohamed Boudra
27f33be0e7 docs: update orchestration docs 2026-05-07 22:17:55 +07:00
Mohamed Boudra
4c11f7c40b test(cli): add Sonnet 4.6 1M to provider models expectation
CLI provider e2e test (15-provider.test.ts) pinned the claude catalog
size; PR #799 added claude-sonnet-4-6[1m] but did not update this list.
Sibling fix to 50405c3b.
2026-05-07 20:04:52 +07:00
Mohamed Boudra
50405c3b6a test(server): add Sonnet 4.6 1M to claude-agent listModels expectation
PR #799 added claude-sonnet-4-6[1m] to the model catalog and updated
claude-models.test.ts but missed the duplicate assertion in
claude-agent.test.ts.
2026-05-07 19:53:02 +07:00
github-actions[bot]
8406559150 fix: update lockfile signatures and Nix hash 2026-05-07 12:41:26 +00:00
Mohamed Boudra
f92a296ce0 chore(release): cut 0.1.70-beta.1 2026-05-07 19:40:10 +07:00
Mohamed Boudra
6786024333 docs(changelog): draft 0.1.70-beta.1 entry 2026-05-07 19:39:22 +07:00
Mohamed Boudra
56855dd6fd fix(server): keep requested cwd when creating an agent (#808)
Workspace lookup was rewriting the agent cwd to the parent workspace cwd. Pass the requested cwd through faithfully and still attach workspaceId for grouping.

Fixes #551
2026-05-07 20:28:57 +08:00
Mohamed Boudra
00a5b27586 Show opencode auth list in OpenCode diagnostic
Helps diagnose missing subscription models — surfaces which providers
the daemon-spawned opencode sees as authenticated.
2026-05-07 19:20:17 +07:00
Christoph Heer
73ed98c623 Add Sonnet 4.6 1M model to Claude model picker (#799)
Closes #334
2026-05-07 20:14:25 +08:00
Mohamed Boudra
927309e867 test(server): real-fs coverage for Linux walker gitignore skip (#806)
Adds a primitive test that initialises a real git repo, writes a
.gitignore covering an ignored subtree, and asserts the Linux working
tree walker registers watchers for kept directories but not for
gitignored ones. Exercises the real `git ls-files -o -i --directory
--exclude-standard` shell-out the new code path depends on.
2026-05-07 19:53:24 +08:00
Mohamed Boudra
2d2ee02ce3 Gate workspace open targets by daemon locality 2026-05-07 18:38:14 +07:00
Mohamed Boudra
4d4fbf7257 Drop dead test for removed Update installed branch
The "shows only the changelog action once the update is installed"
case in update-callout-source.test.tsx pinned the rendering path that
2d9c7747 deleted. The callout now early-returns for any status other
than available/installing/error, so the assertion that the container
contains "Update installed" can never be satisfied.
2026-05-07 18:35:44 +07:00
Mohamed Boudra
67c93dba49 Reorder settings sidebar: Projects after General 2026-05-07 18:27:11 +07:00
Mohamed Boudra
2d9c7747fb Fix misleading 'Update installed' callout flash
The install flow briefly flashed an "Update installed / Restart to use
the new version" callout between clicking Install & restart and the
actual app quit. The button implies action; the restart is already in
flight.

Run quitAndInstall inline (no 1.5s setTimeout) so the IPC sequences
download → install → daemon stop → app quit before returning. Drop the
"installed" rendering branch from the callout so the success path goes
straight from "Installing..." to the window unmounting.
2026-05-07 18:20:22 +07:00
xuzhe
4fa1db8d06 fix(server): stop Linux watcher event storms on busy working trees (#794)
On Linux fs.watch isn't recursive, so each working tree manually walks
the entire subtree and registers a per-directory watcher. Every inotify
event then re-scans the tree and rebuilds watchers. A working tree
containing a directory that's continuously written (e.g. test-runtime
data, build artefacts) drives the event loop to 100% CPU and the
daemon stops responding on its websocket.

Skip git-ignored directories when walking — load `git ls-files -o -i
--directory --exclude-standard` once per root (5 min cache) and skip
its entries. Cap the walked directory count at 5000 so a misconfigured
repo can't blow past the inotify budget. Add a 2s cooldown between
refresh passes so a flurry of events collapses into one re-scan
instead of a tight loop.

Co-authored-by: xuzhe.7766 <xuzhe.7766@bytedance.com>
2026-05-07 19:17:33 +08:00
Mohamed Boudra
92be6c0cba Replace skills auto-sync with explicit install/update/uninstall (#797)
* Replace skills auto-sync with explicit install/update/uninstall

Skills no longer sync silently on every desktop launch. Settings now
shows Install (fresh), Update (with a confirm dialog listing per-skill
add/update/delete ops), or Uninstall depending on what's on disk.

A manifest at ~/.agents/skills/.paseo-manifest.json tracks Paseo-owned
skills so uninstall removes only what we wrote, never user content.
Existing users migrate transparently — first read synthesises a
manifest from on-disk hashes, so an unchanged install flips silently
to up-to-date.

Also restructures the integrations layer: skills/ and cli-install/
each own their files behind a designed index.ts. The shared
integrations-manager.ts is gone.

* Rewrite skills install flow with disk-truth model

Drops the manifest sidecar, migration logic, and the
agentsDirHasSkillContent heuristic that misread user-authored skills
as Paseo content. Replaces them with a hardcoded PASEO_SKILL_NAMES
list (current bundle + paseo-chat for retired-skill cleanup); the
disk is the source of truth for what's installed.

Renames the SkillsState "fresh" → "not-installed" everywhere
(server, IPC parser, hook test mocks). Install and update share an
applySkills body. Errors propagate instead of being swallowed.

Fixes the case where Uninstall left state at "drift" because the
user had personal skills under ~/.agents/skills/.

* Stop symlinking claude skills, write real files

If the user has ~/.claude/skills symlinked to ~/.agents/skills, the
per-skill symlink path resolved through the parent symlink and fs.rm'd
the agents-side files we'd just written. Mirror real files to claude
the same way as codex; agents stays the canonical source-of-truth dir.

* Make Uninstall button visible (outline, not ghost)
2026-05-07 18:41:13 +08:00
Ethan Greenfeld
4f0b264886 Fix ACP terminal shell command spawning (#793) 2026-05-07 16:24:30 +08:00
Mohamed Boudra
bad304c2d3 Detect GitHub issue/PR URL in composer search
Pasting a GitHub issue or pull request URL into the composer's
issue/PR picker now searches by the number from the URL instead
of treating the full URL as a keyword that returns no matches.
2026-05-07 15:23:45 +07:00
Mohamed Boudra
0785ee31f0 Match model description in combobox search
Search now matches across model label, id, provider, and description
with multi-token fuzzy matching, so "kimi zen" finds the OpenCode Zen
Kimi model.
2026-05-07 15:23:16 +07:00
Mohamed Boudra
faf664d803 Fix desktop CLI passthrough tty handling (#791)
* Fix desktop CLI passthrough tty handling

* Fix desktop daemon manager test drift

* Update daemon stop test after main merge

* test(cli): isolate npx cache per CLI test
2026-05-07 15:14:18 +08:00
Mohamed Boudra
6fb2fe2283 Fix pane shortcuts in editable fields 2026-05-07 13:28:42 +07:00
Mohamed Boudra
cdce9a1235 Add spacing after Codex goal status 2026-05-07 13:08:45 +07:00
Mohamed Boudra
285d4edf23 fix(server): harden findExecutable + remove gratuitous realpath from spawn paths (#787)
* Phase 0: baseline tests for probeExecutable

* Phase 1: invert self node env handling

* Phase 2: refactor probeExecutable to execCommand

* Phase 3: push POSIX executable lookup to system which

* Fix probe test spawn-failure fixture

* Guard system which executable lookup
2026-05-07 14:03:15 +08:00
Mohamed Boudra
5c86956fef Render Codex image thread items as path markdown (#785) 2026-05-07 13:19:31 +08:00
Mohamed Boudra
80bdb4d45b Use tree-kill for daemon shutdown (#788)
* Use tree-kill for daemon shutdown

* fix: update lockfile signatures and Nix hash

* Use tree-kill for daemon shutdown

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-05-07 13:18:39 +08:00
Mohamed Boudra
7558bd7947 Fix dev override host bootstrap (#789) 2026-05-07 13:08:10 +08:00
Mohamed Boudra
16944da45b Fix MCP terminal capture scrollback 2026-05-07 11:59:13 +07:00
Mohamed Boudra
55ec13f153 fix(cli): honor PASEO_PASSWORD env var (fix #776) (#782)
* fix(cli): honor PASEO_PASSWORD env var (fix #776)

The CLI only extracted passwords from `tcp://host?password=` URIs, so
`PASEO_PASSWORD=xxxx paseo ls` connected without auth and was rejected
by the daemon with "Password required". Fall back to the env var when
the host (TCP or otherwise) carries no password.

* docs: document PASEO_PASSWORD env var as a CLI auth source

Pairs with the CLI fix in this branch — the "Connecting with a password"
section now lists both the tcp URI query and the env-var fallback, with
their precedence. Also clarify the dual role (daemon vs CLI) of
PASEO_PASSWORD in the env var reference.
2026-05-06 23:22:22 +08:00
Mohamed Boudra
c36f4cfee4 Vendor ACP provider catalog 2026-05-06 20:00:40 +08:00
Mohamed Boudra
ac375f152d Wrap desktop IPC in shared mutation/query hooks (fix #761) (#765)
* Wrap desktop IPC calls in shared hooks

* Unslop desktop IPC hook callsites

* Redesign desktop IPC hooks around domains
2026-05-06 19:58:17 +08:00
Zitao Xiong
24bdd3c8ad fix(relay): use TLS for any port-443 relay endpoint (#774)
* fix(relay): use TLS for any port-443 relay endpoint

`shouldUseTlsForDefaultHostedRelay` only returned true for
`relay.paseo.sh:443`. Self-hosted relays on port 443 connected via
`ws://` instead of `wss://`, causing HTTP 400 from the TLS server.

Port-based detection: port 443 → wss, everything else → ws.

* Test port-based hosted relay TLS detection

Cover the new behavior: any port-443 endpoint (hosted Paseo or
self-hosted) and IPv6 hosts return true, non-443 ports and malformed
input return false.

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-05-06 19:56:32 +08:00
Mohamed Boudra
7e8187ddc7 fix: never rename worktree branches from create_agent path
Auto-naming should only run during worktree creation (mnemonic on create,
async LLM rename moments after). Creating a new agent inside an existing,
properly-named branch must not trigger a rename.
2026-05-06 18:33:36 +07:00
Mohamed Boudra
2e45650f22 Allow self-hosted relays to opt into wss:// (#767)
* Add relay TLS schema fields

* Wire relay TLS into websocket URLs

* Propagate relay TLS from pairing offers

* Expose relay TLS opt-in

* Mark relay TLS fallback for removal

* Preserve explicit relay TLS false

* Align relay TLS defaults outside config loader
2026-05-06 17:25:16 +08:00
Mohamed Boudra
6c319d05dd feat(codex): wire /goal slash command with mid-turn support (#711)
Adds optional `tryHandleOutOfBand` on AgentSession so providers can
handle slash commands as side effects without allocating a turn or
tripping the activeForegroundTurnId gate. Codex provider implements it
for /goal (set/pause/resume/clear), version-gated to codex >= 0.128.0
with `--enable goals`. Mid-turn /goal pause now works alongside a live
turn against the same thread.

E2E test against real codex 0.128.0 covers set + mid-turn pause + clear.
2026-05-06 16:50:02 +08:00
Mohamed Boudra
9eafab5a50 Fix index startup navigation test mock
The cold workspace URL navigation fix changed the / startup path for a matching persisted workspace. Index no longer renders a workspace Redirect there; it calls navigateToWorkspace so the centralized workspace navigation path owns the transition, then keeps the startup splash visible while that imperative navigation runs.

Update the test mock to expose navigateToWorkspace and assert that behavior directly. This keeps the test aligned with the route-shape fix and prevents CI failures on branches rebased over main.
2026-05-06 15:46:01 +07:00
github-actions[bot]
efff5b8454 fix: update lockfile signatures and Nix hash 2026-05-06 08:23:22 +00:00
Mohamed Boudra
2c2ded7492 Upgrade Electron to 41.2.0 2026-05-06 15:22:12 +07:00
Mohamed Boudra
7932e38d0d Fix browser reload focus and devtools handling
Cmd+R was scoped through a stale Electron-side active browser value. Opening or focusing a browser pane set activeBrowserPaneId, but switching to an agent tab only updated workspace layout state. Electron still remembered the old browser and the app menu reloaded that hidden/previous browser instead of the app.

Make workspace layout the authority. Derive the active browser from focusedPaneId plus the focused pane's focusedTabId, and sync browserId|null to Electron from one WorkspaceScreen effect. BrowserPane no longer marks itself active from URL-bar or webview focus handlers, so call sites do not have to remember to clear Electron state when focus moves to an agent, terminal, or another pane.

Electron now treats the synced value as the workspace-active browser, not an independently observed focus source. Menu reload uses that workspace-derived browser when present; otherwise it reloads the app window. Direct Cmd+R events that originate inside a browser guest still reload that guest through the guest before-input-event path.

Browser webviews now own their guest context menu in the main process. Add copy/paste/select-all and a development Inspect Element action for attached browser guest WebContents.

DevTools opening is routed through the main process by browserId. Docked-right DevTools did not become visible for browser guest contents in practice, even though inspect mode activated, so the final behavior opens detached DevTools for both the toolbar button and Inspect Element. The renderer logs bridge/result failures, and main logs registration/open/inspect outcomes for future debugging.

Regression coverage is at the workspace layout derivation layer: focused browser id follows the focused pane's active browser tab and becomes null when the active tab is not a browser. Verified with targeted layout-store tests, navigation E2E, settings E2E, npm run typecheck, and npm run lint.
2026-05-06 15:15:15 +07:00
Mohamed Boudra
5e5fc97798 Fix cold workspace URL sidebar navigation
Directly opening a workspace URL created a different React Navigation state shape than entering from /. Expo Router parsed the URL as a workspace layout route with a nested index child whose route.path and params pointed at the original workspace. Later sidebar navigation used router.dismissTo for another workspace. React Navigation's POP_TO reused the existing workspace route by name and updated the parent params, but preserved the nested child state. The result was a hybrid state: parent workspace params for B, child index path for A. Expo Router serialized the focused child path, so the address bar stayed on A and sidebar clicks looked like no-ops.

The fix is to stop using workspace/[workspaceId]/_layout.tsx as the actual screen. That layout did not render a Slot, so it only added an extra navigation level for Expo Router to preserve. Move the workspace UI, bootstrap boundary, open-intent consumption, and retained WorkspaceDeck into the leaf index route, making /h/:serverId/workspace/:workspaceId one real rendered route.

Keep workspace switches on navigateToWorkspace/router.dismissTo. This is not another replace workaround: replace previously created duplicate mounted workspace shells. The route-shape fix removes the stale nested child state that made dismissTo fail after cold deep links.

Also route archive/new-agent and notification flows through the centralized workspace navigation path so they do not bypass the same route contract.

Regression coverage: cold-load workspace A, click workspace B in the sidebar, and assert the URL changes to B. Verified with workspace-navigation-regression.spec.ts, settings-navigation.spec.ts, npm run typecheck, and npm run lint.
2026-05-06 15:13:53 +07:00
Mohamed Boudra
868479c493 Fix workspace navigation regression on web (#772)
Centralize workspace navigation behind a small route-derived API so the workspace shell reads active selection from the current Expo Router route instead of a separately synchronized store.

Remove the stale layout-driven active-workspace write that regressed web after 02e5b564.
2026-05-06 13:22:31 +08:00
Mohamed Boudra
0eacc98e17 website: add /cloud design partners page and shared shell
- New /cloud page with signup form (email, name, company, role, message)
  posting to a Discord webhook via a TanStack Start server function on
  the Cloudflare Worker. Webhook URL read from DISCORD_WEBHOOK_URL secret.
- Extract SiteShell, SiteFooter, FAQItem from landing-page.
- Migrate /privacy, /download, /blog, /changelog, /cloud to SiteShell so
  they share header + footer with consistent width.
- Add /cloud to header and footer nav.
- Make /docs sidebar full-width; bump footer text from xs to sm.
- Add Cloudflare Workers types and ignore .dev.vars.
2026-05-06 10:45:14 +07:00
Mohamed Boudra
ea4d6bb7ce Extract cwd resolution into testable function 2026-05-06 08:55:11 +07:00
Mohamed Boudra
40543128f7 Fix Codex import default mode 2026-05-05 22:49:55 +07:00
Mohamed Boudra
eac392e366 docs: tighten skills blurb and add hr before star chart 2026-05-05 22:22:02 +07:00
Mohamed Boudra
5396811c58 docs: tighten skills blurb 2026-05-05 22:19:55 +07:00
Mohamed Boudra
11383fa356 docs: rename to Skills and reframe with capability blurb 2026-05-05 22:19:41 +07:00
Mohamed Boudra
322bb1d962 docs: replace skills code block with bullet list 2026-05-05 22:17:21 +07:00
Mohamed Boudra
2402620d2f docs: add community section with paseo-relay link 2026-05-05 22:15:48 +07:00
Mohamed Boudra
3ac3b71ad3 docs: add Community section with paseo-relay link 2026-05-05 22:14:49 +07:00
Mohamed Boudra
02e5b56408 Fix duplicate workspace shell navigation 2026-05-05 21:55:57 +07:00
Mohamed Boudra
60531b5e1b refactor: long-tail type-aware sweep (T3.c) (#759)
* refactor: long-tail type-aware sweep (T3.c)

Cluster T3.c — 15 files, 24 errors cleared.

Rules fixed:
- no-useless-default-assignment: 6 → 0 (mcp-server.ts — Zod .default() makes destructuring defaults redundant)
- no-unnecessary-type-assertion: 1 → 0 (workspace-registry-model.ts — remove redundant ! on string[])
- no-unsafe-enum-comparison: 3 → 0 (use-push-token-registration.ts, use-image-attachment-picker.ts — use PermissionStatus enum values)
- no-redundant-type-constituents: 6 → 0 (workspace-setup.ts, tool-call-detail-state.ts, workspace-draft-agent-tab.tsx, stream.test.ts, desktop-settings-commands.ts — unknown | T → unknown)
- no-unnecessary-type-parameters: 4 → 0 (indexeddb-attachment-store.test.ts, use-archive-agent.ts, class-mocks.ts, agent-stream-coalescer.ts — narrow interface / remove unused generics)
- no-unnecessary-type-conversion: 4 → 0 (session-context.tsx — remove String() on already-string values; class-mocks.ts — remove String(prop) after symbol guard)
- no-implied-eval: 1 → 0 (agent-stream-coalescer.test.ts — narrow timer interface to function-only, eliminating string overload)

Deferred (need caller updates or extra files beyond 15-file cap):
- no-unnecessary-type-parameters: host.ts:onResized/onDragDropEvent, electron/events.ts (callers pass typed handlers; removing generic breaks contravariance without cast)
- no-base-to-string: ~21 errors, 18 files
- restrict-template-expressions: ~11 errors, 9 files
- await-thenable: ~7 errors, 4 files
- no-unnecessary-type-conversion: ~9 remaining errors, 9 files

* fix(server): restore MCP tool handler defaults that the linter mis-flagged

The `no-useless-default-assignment` rule fires because Zod's `.default()`
chain makes the TypeScript type non-optional, so the linter treats the
destructuring defaults as redundant. But the MCP framework invokes
handlers with raw (untransformed) params — Zod defaults are not applied
before the call. The destructuring defaults are the runtime safety net.

Reverts the three handler changes from T3.c:
- send_prompt: background = false, notifyOnFinish = false
- list_agents: includeArchived = false, sinceHours = 48, limit = 50
- get_terminal_lines: stripAnsi = true

The no-useless-default-assignment errors on these lines remain and are
deferred to a future cluster that addresses the MCP framework integration.
2026-05-05 22:38:55 +08:00
Mohamed Boudra
2415858d8f feat(website): clarify download page with Server section and copy buttons
Splits the old "Web & CLI" block into separate Web and Server sections so
new users can find the headless install flow without reading the FAQ.
Each section gets a one-line subtitle, and command rows reuse the
CodeBlock component (extracted from CommandDialog) with a new compact
size variant. Also adds Homebrew and Nix install rows, and stacks rows
vertically on small screens.
2026-05-05 21:31:10 +07:00
Somasundaram Ayyappan
9f01b4b7a2 fix: include untracked files in checkout shortstat (#608) (#762)
Count lines in untracked files when computing branch diffstat.
Adds file size limit, empty file handling, and CRLF normalization.
2026-05-05 21:10:54 +08:00
github-actions[bot]
425aa20747 fix: update lockfile signatures and Nix hash 2026-05-05 13:03:21 +00:00
Mohamed Boudra
685a3d0ee2 refactor(server,app): lift return types and parse at boundaries (T2 typeaware production sweep) (#758)
* refactor(server,app): lift return types and parse at boundaries (T2 typeaware production sweep)

* fix(server): align acp-agent test assertions with pino call shape
2026-05-05 19:58:56 +07:00
Mohamed Boudra
53e1be4da6 refactor(server/tests): solve class-stub mock pattern (T1.e) (#757)
Introduce createStub<T> (Proxy-based) and asInternals<T> (single-cast wrapper)
in test-utils/class-mocks.ts to replace all as unknown as casts in server test
files that construct fakes of private-field classes or access class internals.

- class-mocks.ts: one justified as unknown as T cast inside createStub; Proxy
  throws on any unstubbed method call, adding real runtime safety vs bare cast
- session-stubs.ts: all 19 as unknown as casts replaced with createStub/asInternals
- websocket-server.notifications.test.ts + relay-reconnect.test.ts: createStub
  for HTTPServer / pino.Logger / AgentManager stubs; asInternals for internals access
- snapshot-mutation-ownership.test.ts: createStub for SessionOptions field stubs
- session.workspace-git-watch.test.ts: createStub for all SessionOptions fields;
  asInternals for SessionInternals access
- session.workspace-resolution-invariants.test.ts: createStub + asInternals
- acp-agent.test.ts, claude-agent.redesign.test.ts, codex-app-server-agent.test.ts,
  acp-wrapper-smoke.test.ts: asInternals for all internal-access casts

Errors cleared: ~80 as unknown as casts removed across 11 files.
Lint: 0 errors on all 11 files. Typecheck: green.
2026-05-05 19:58:56 +07:00
Mohamed Boudra
eb6d19969d refactor: explicit returns + void floating promises (T3.a typeaware sweep) (#756)
Cluster T3.a — mechanical fixes for consistent-return and no-floating-promises.

Files touched (15):
- server/shared/tool-call-display.ts — default throw in buildCanonicalDetailDisplay switch
- server/agent/prompt-attachments.ts — default throw in renderPromptAttachmentAsText switch
- server/workspace-directory.ts — default throw in getSortValue switch
- server/agent/mcp-server.ts — default throw in mcpCreateWorktreeInput switch
- server/terminal/terminal-session-controller.ts — default return in dispatch switch
- server/daemon-e2e/agent-configs.ts — default return false in isProviderAvailable switch
- server/agent/agent-manager.ts — void dispatchSessionEvent fire-and-forget call
- server/agent/mcp-shared.ts — void 4 notify() floating calls
- app/agent-status-bar.utils.ts — default throw in getStatusSelectorHint switch
- app/keyboard-shortcuts.ts — default throw in resolvePayload switch
- app/resolve-agent-form.ts — default throw in resolveAgentForm switch
- app/ui/dropdown-menu.tsx — void .then() + return undefined for promise/always-return
- app/use-agent-form-state.ts — void refreshSnapshot() floating call
- cli/worktree/create.ts — default throw in toDaemonCreateInput switch
- cli/onboard.ts — return process.exit(1) to satisfy consistent-return in catch

Deferred (outside 15-file cap or in EXCLUDE list):
- server/worktree-session.ts — getSortValue (same pattern, cap reached)
- Multiple files with other errors (unbound-method, unsafe-type-assertion) are separate clusters
2026-05-05 19:58:56 +07:00
Mohamed Boudra
df5c59263e refactor(typeaware): no-unnecessary-type-conversion + unbound-method sweep (T3.b partial) (#754)
* refactor: clear no-unnecessary-type-conversion + unbound-method (T3.b partial)

Fixes 15 files within the hard cap:

unbound-method (interface method shorthand → property function syntax):
- pane-context.tsx: PaneContextValue + PaneFocusContextValue (fixes agent-panel, browser-panel, draft-panel)
- provider-runner.ts: ProviderTurnRunner interface
- process-tree.ts: process.kill.bind(process)

no-unnecessary-type-conversion (remove redundant Boolean()/String() wraps):
- agent-timeline-store.ts, worktree-session.ts, pairing-qr.ts
- sherpa-realtime-session.ts, sherpa-stt.ts (String() → direct call chain)
- workspace-execution.ts, confirm-dialog.ts, use-archive-agent.ts
- use-agent-history.ts, left-sidebar.tsx, autocomplete.tsx, dropdown-menu.tsx

Deferred (9 errors across 7 files, all single-line Boolean(hovered) removals):
project-picker-modal.tsx, agent-list.tsx, branch-switcher.tsx,
file-explorer-pane.tsx, workspace-open-in-editor-button.tsx,
browser-pane.electron.tsx, sortable-inline-list.web.tsx

* fix(app): preserve Boolean coercion fallback in archive state lookups

readPendingState(...)[key] and (pendingQuery.data ?? {})[key] return
boolean | undefined at runtime — noUncheckedIndexedAccess is off so
TypeScript types this as boolean but the actual value can be undefined.
Add ?? false to maintain the explicit boolean return contract.
2026-05-05 19:58:56 +07:00
Mohamed Boudra
059d543c97 refactor(server/tests): clear 146 type-aware lint errors in session tests (T1.a) (#753)
Replace `as unknown as SessionOptions["X"]` call-site casts with typed stub
helpers from a new `test-utils/session-stubs.ts` module. Unsafe assertions
are now contained to that single infrastructure file, keeping all call sites
in the two fully-fixed test files clean.

Files fully fixed (typeAware: true → 0 errors):
- session.workspaces.test.ts: 119 → 0
- session.test.ts: 27 → 0

Helpers added in session-stubs.ts:
- asSessionLogger / asAgentManager / asAgentStorage / asDownloadTokenStore /
  asPushTokenStore / asChatService / asScheduleService / asLoopService /
  asCheckoutDiffManager / asDaemonConfigStore / asTerminalManager /
  asGitHubService / asWorkspaceGitService / asScriptRouteStore /
  asWorkspaceScriptRuntimeStore
- asSessionInternals<T> for private session access
- filterByType / findByType message helpers
- createProviderSnapshotManagerStub returning typed spy refs
2026-05-05 19:58:56 +07:00
Mohamed Boudra
eb16829f51 refactor(server/tests): replace unsafe type assertions with Reflect + Zod (T1.b partial) (#752)
Cluster T1.b — first wave of no-unsafe-type-assertion fixes across agent
provider and MCP test files. Clears all type-aware lint errors in 6 files;
remaining 4 files (class-mock patterns) deferred to T1.b2.
2026-05-05 19:58:56 +07:00
Mohamed Boudra
b1d3867da9 refactor(server): parse daemon/ws/wire test responses with Zod (T1.c typeaware sweep) (#750)
Fixes all reachable typescript-eslint(no-unsafe-type-assertion) errors
in the four in-scope test files by:
- Adding Zod schemas (WireEnvelopeSchema, BinaryFrameSchema,
  RuntimeMetricsLogSchema) and helpers (parseSentEnvelope, parseSentFrame,
  assertStr) to parse wire frames instead of casting JSON.parse() results
- Replacing `JSON.parse(x) as { ... }` with Zod-validated helpers
- Replacing `mock.sent[N] as string` / `String(mock.sent[N])` with assertStr()
- Replacing `frame as Uint8Array` with direct type annotation (Buffer extends Uint8Array)
- Replacing `session.args.onMessage as (() => void) | undefined` with typeof
  function guard and direct invocation
- Replacing `metricsCall![0] as RuntimeMetricsLog` with RuntimeMetricsLogSchema.parse()

Remaining `as unknown as SomeClass` patterns in constructor mocks and
`client as unknown as { ... }` internal-access patterns are out of scope:
they require extracting interfaces from concrete classes (AgentManager,
AgentStorage, FileBackedChatService, etc.) with private members, or
refactoring the client API to expose internal methods — neither is safe
to do in a test-only sweep.
2026-05-05 19:58:56 +07:00
Mohamed Boudra
006a79ed01 refactor(app/relay): parse test responses with Zod (T1.d typeaware sweep) (#749)
Replace unsafe type assertions with Zod schema parsing:
- app/utils/project-config-form.test.ts: Use PaseoConfigRawSchema.parse()
  instead of 'as unknown as PaseoConfigRaw' (~10 errors fixed)
- server/daemon-e2e/relay-transport.e2e.test.ts: Use ConnectionOfferSchema
  and WSOutboundMessageSchema.parse() instead of inline type casts (~8 errors fixed)

Deferred to follow-up cluster (architectural decisions needed):
- host-runtime.test.ts: FakeDaemonClient mock needs proper interface
  implementation (requires source file changes)
- cloudflare-adapter.test.ts: WebSocket/DurableObjectState mocks need
  design review (external Cloudflare types)

Target rule: typescript-eslint(no-unsafe-type-assertion)
Errors cleared: 18/76 in cluster (61 deferred)
2026-05-05 19:58:56 +07:00
Mohamed Boudra
0d80394180 test(app/e2e): sessions-screen empty state (Cluster G7) (#742)
* test(app/e2e): cover sessions-screen empty state (Cluster G7)

Adds one E2E test — opens Sessions on a fresh workspace with no agents
and asserts the "No sessions yet" placeholder renders. Uses `withWorkspace`
fixture (no agent seeding) so the empty branch runs for the first time.

Also exports `expectSessionsEmptyState` helper from archive-tab helpers
for reuse in future session-related specs.

* fix(app/e2e): run sessions-empty test before archive-tab agents are created

The sessions screen shows global agent history for the daemon. Running
the empty-state test last meant the reconciliation tests had already
created 6 agents, so "No sessions yet" never rendered.

Moving the describe block first ensures it runs on a clean daemon
(workers:1, fullyParallel:false, archive-tab.spec.ts is first alphabetically).

* refactor(app/e2e): guard sessions-empty ordering + dedup selector constant

Addresses reviewer feedback on the ordering fragility:

- Add NOTE comment above Sessions screen empty state describe explaining
  why it must remain first in the file (daemon history is global; the
  reconciliation tests below call createIdleAgent which would pollute it).

- Add a fast-fail guard in expectSessionsEmptyState: asserts 0 agent rows
  with a 5s timeout so a future maintainer sees an immediately actionable
  failure message rather than a mysterious "No sessions yet" timeout.

- Extract AGENT_ROW_SELECTOR constant to eliminate the duplicated
  [data-testid^="agent-row-"] string shared by getSessionRowByTitle and
  the new guard.

* fix(app/e2e): move sessions-empty to 00-prefixed file to survive new specs

agent-stream-ui.spec.ts (merged in #743) sorts before archive-tab.spec.ts
and creates agents, breaking the empty-state test. Any future a*-*.spec.ts
has the same risk.

Fix: move the test to 00-sessions-empty.spec.ts — digit prefix sorts
before all alpha-named specs, making the ordering constraint explicit at
the filesystem level.

Also adds a beforeAll daemon probe that fails fast with a clear message
if any pre-existing agents are found, covering both ordering violations
and stale daemon state from a previous run.

Supporting changes:
- Add fetchAgentHistory to ArchiveTabDaemonClient interface (typed as
  Array<{ id: string }> — enough for the count check)
- Remove the test and NOTE comment from archive-tab.spec.ts
- Update expectSessionsEmptyState guard comment to reference the new file
2026-05-05 19:58:56 +07:00
Mohamed Boudra
9721dadbe9 test(app/e2e): picker keyboard interaction tests (Cluster G8) (#744)
* test(app/e2e): add picker keyboard-interaction tests (Cluster G8)

Cover branch-picker keyboard contract: open via Space, navigate with
ArrowDown/ArrowUp, select with Enter, close with Escape.

Adds six helpers to helpers/new-workspace.ts:
openBranchPicker, selectPickerOptionByKeyboard, closeBranchPicker,
expectPickerOpen, expectPickerClosed, expectPickerSelected.

Also moves delayBrowserAgentCreatedStatus and its private helpers out of
new-workspace.spec.ts into the helpers module where they belong.

* fix(app/e2e): address picker keyboard test review feedback

- Add { timeout: 30_000 } to expectPickerClosed (FadeOut animation safety)
- Simplify selectPickerOptionByKeyboard: ArrowDown → Enter (remove redundant ArrowUp)
- Migrate expectStartingRefPickerTriggerPr trigger selector from testID to ARIA role

* fix(app/e2e): fix picker keyboard test — open via click not Space

RN Web Pressable renders as <div role="button"> which does not fire
onPress from a programmatic Space key event. Switch openBranchPicker
to trigger.click() so the picker reliably opens in CI headless Chrome.

Keyboard behaviour (ArrowDown + Enter, Escape) is still exercised by
selectPickerOptionByKeyboard and closeBranchPicker respectively.
2026-05-05 19:58:56 +07:00
Mohamed Boudra
9bd1407ca6 test(app/e2e): mobile sidebar open/close transition (Cluster G6) (#746)
* test(app/e2e): sidebar query pause + mobile open/close transition (Cluster G6)

G6.1: assert fetch_workspaces_request stops being sent when desktop sidebar
is closed (CDP WebSocket frame counting, no store injection).

G6.2: assert mobile sidebar panel animates in/out at 390×844 viewport via
toBeInViewport on translateX-animated element.

Adds installWorkspaceFetchMonitor, expectWorkspaceListSubscribed,
closeSidebar, openMobileAgentSidebar, closeMobileAgentSidebar,
expectMobileAgentSidebarVisible, and expectMobileAgentSidebarHidden helpers.

* test(app/e2e): mobile sidebar open/close transition (Cluster G6)

G6.1 (query-pause perf invariant) reclassified as a unit-test follow-up
— counting internal RPC frames in E2E is banned per updated roadmap.

G6.2: asserts mobile sidebar panel animates in/out at 390×844 viewport
via toBeInViewport on the translateX-animated sidebar-sessions element.

Adds openMobileAgentSidebar, closeMobileAgentSidebar,
expectMobileAgentSidebarVisible, and expectMobileAgentSidebarHidden helpers.
2026-05-05 19:58:55 +07:00
Mohamed Boudra
3905b2e864 test(app/e2e): stream auto-scroll and working-indicator→copy-button (Cluster G5) (#743)
* test(app/e2e): stream auto-scroll and working-indicator→copy-button (Cluster G5)

Wire in the unused agent-bottom-anchor helpers and add two new agent stream
UI specs: auto-scroll stays pinned to the bottom across token bursts, and
the inline working-indicator transitions to a copy-button when the stream
ends. Both tests use the mock provider so there is no real LLM dependency
in CI.

Adds three helpers to helpers/agent-stream.ts:
- expectInlineWorkingIndicator
- expectTurnCopyButton
- expectScrolledToBottom (wraps agent-bottom-anchor)

* fixup: address review blockers and nits

- Replace expectScrolledToBottom passthrough with expectScrollFollowsNewContent
  that encapsulates readScrollMetrics → waitForContentGrowth → expectNearBottom
- Remove direct agent-bottom-anchor imports from spec body (DSL leak)
- Add awaitAssistantMessage before expectInlineWorkingIndicator to anchor on
  real content before asserting the working indicator
- Add comment to expectInlineWorkingIndicator explaining why testId is used
  (animated spinner View has no ARIA role)
2026-05-05 19:58:55 +07:00
Mohamed Boudra
b92fb9392e fix(cli-tests): prepend root node_modules/.bin to PATH in test runner (#737)
When npm runs a workspace script, it only adds the workspace-local
node_modules/.bin to PATH. packages/cli/node_modules/.bin doesn't exist
because all packages are hoisted to the root. This caused `npx paseo`
inside test files to miss the local binary and fall back to the npm
registry, where a stale npx cache entry for @getpaseo/cli was missing
bin/paseo — failing the chmod with ENOENT and exiting 254.

07-agent-stop was the first test in shard 2/3 to call `npx paseo` without
spinning up a daemon first, so it reliably hit the broken cache path.

Fix: prepend root/node_modules/.bin to PATH in the spawn env for each
test subprocess.
2026-05-05 19:58:55 +07:00
Mohamed Boudra
5fc1fb1761 fix(app/e2e): fix composer-lock test — mock provider + prompt so lock releases (#739)
* fix(app/e2e): fix composer-lock test — use mock provider + prompt so lock releases

Two bugs in the "composer is locked while new workspace agent is being
created" test:

1. expectComposerDisabled used toBeDisabled() but React Native TextInput
   with editable={false} renders as <textarea readonly> on web, not
   <textarea disabled>. Fixed to not.toBeEditable().

2. clickNewWorkspaceButton created an agent with no prompt, leaving the
   agent permanently "idle". showPendingCreateSubmitLoading only clears
   when authoritativeStatus is non-bootstrapping, but "idle" counts as
   bootstrapping — so the composer lock never released. Fixed by opening
   the new-workspace composer, filling a prompt, then clicking Create so
   the mock agent transitions to "running" after agent_created is released.

Also switches buildCreateAgentPreferences to provider "mock" with the
ten-second-stream model so E2E tests exercise app behavior without
depending on real provider availability.

* fix(app): add aria-checked to Switch for E2E toBeChecked() assertions

React Native Web does not map accessibilityState.checked to aria-checked
for role="switch", so Playwright's toBeChecked() always finds the element
unchecked. Adding aria-checked={value} directly to the Pressable sets the
attribute explicitly.

Update the switch.test.tsx mock to accept and pass through the explicit
aria-checked prop, keeping the mock faithful to the fixed component.
2026-05-05 19:58:55 +07:00
Mohamed Boudra
43ef9d77c1 test(app/e2e): cover project-settings error-UX paths (Cluster G3) (#731)
* test(app/e2e): cover 5 error-UX paths + host indicator + script removal for project settings

Add helpers/project-settings.ts DSL helpers and extend projects-settings.spec.ts
with 5 new tests covering the error paths dropped by PR #725:

- stale_project_config callout + disabled save + reload recovery
- invalid_project_config read callout + reload after fix
- write_failed callout + retry + reload recovery
- single-host static indicator vs picker chip
- script removal via kebab menu + confirm dialog

* refactor(app/e2e): unslop project-settings helpers and spec

- Fix removeProjectScript: derive trigger testID from row testID instead
  of using scoped locator (which was timing out)
- Extract inline writeFile call in invalid-config test to restorePaseoConfig helper
- Replace raw testID click in write_failed test with clickReloadProjectSettings
- Remove writeFile from spec imports; drop defensive ?? "" fallback

* test(app/e2e): add read-transport and offline no-target tests for project settings

- Add read-transport failure test: WS-level drop during readProjectConfig triggers
  read-transport-callout; Reload retries until WS reconnects and refetch succeeds.
- Add no-target test: WS drop after form load triggers NoEditableTarget via live
  connectionStatus check (useHostRuntimeSnapshot) on the selected host.
- Hoist openProjects/editWorktreeSetup from spec body into project-settings helper.
- Fix expectNoProjectSettingsError to accept optional timeout (needed for toPass loop).
- Add isHostGone to renderContent: after readQuery errors are checked, offline/error
  connectionStatus renders NoEditableTarget without unmounting ProjectSettingsBody.

* fixup(app/e2e): correct misleading comments on WS close → error-state mapping
2026-05-05 19:58:55 +07:00
Mohamed Boudra
1348cf908a test(app/e2e): add composer-attachments spec (8 behaviors) (#734)
* test(app/e2e): add composer-attachments spec covering 8 attachment behaviors

Restores E2E coverage for composer attachment behaviors dropped in PR #720:
plus-menu visibility, GitHub combobox lazy search, image lightbox, pill render,
pill removal (with hover-reveal), queue-on-running-agent, review-pill suppression
(test.fixme pending store seeding bridge), and Escape interrupt draft preservation.

Includes accessibilityRole="button" on QueuedMessageRow Pressables (was rendering
as generic, breaking role-based selectors) and an opt-in E2E debug surface on
useWorkspaceAttachmentsStore (localStorage gated, needed for the fixme test).

* refactor(app/e2e): unslop composer-attachments spec and helpers

Remove dead `expectComposerLocked` export (never imported), trim verbose
JSDoc on `pressInterruptShortcut`, and correct the test.fixme comment
which said the store wasn't window-exposed (it is, as of the parent commit).

* fix(app/e2e): address PR #734 review blockers for composer-attachments spec

- Remove window.__paseoWorkspaceAttachmentsStore exposure (hard ban on internal
  state injection)
- Merge helpers/composer-attachments.ts into helpers/composer.ts; delete old file
- Add openGithubWorkspace, selectGithubOption, expectGithubAttachmentPill,
  expectComposerDisabled, expectAttachButtonDisabled helpers to composer.ts
- Extract delayBrowserAgentCreatedStatus from new-workspace.spec.ts into
  helpers/new-workspace.ts so it can be shared
- Add real GH issue/PR pill tests using createTempGithubRepo fixtures
- Rewrite lock-state test to assert textarea disabled + attach button disabled
  during in-flight workspace creation (submitBehavior=preserve-and-lock)
- Add test.fixme with detailed explanation for workspace-review pill (requires
  diff pane automation not yet in E2E harness)
- Add test.fixme for browser-element pill (Electron-only, not testable in
  headless Chromium)

* refactor(app/e2e): unslop composer helpers and spec after blockers fix

- Remove AI section headers from composer.ts (not in project convention)
- Fix fillComposerDraft: drop redundant click() before fill()
- Fix selectGithubOption: extract locator to local var instead of double getByTestId()
- Use clickNewWorkspaceButton in lock-state test instead of raw Create button locator
- Drop obvious PNG constant comment
2026-05-05 19:58:55 +07:00
Mohamed Boudra
b3d476ce49 feat(app/e2e): add PR pane E2E spec with fixture-based seeding (#732)
* feat(app/e2e): add PR pane E2E spec with fixture-based seeding

Adds 7 tests covering open/merged/closed/draft states, check pill
counts, activity row count, and the empty-checks graceful render.
Fake gh CLI now reads .paseo-e2e-pr.json and .paseo-e2e-timeline.json
from the workspace cwd so each test gets isolated fixture data.

* refactor(app/e2e): switch PR pane spec to real GitHub fixtures

Replace the fixture-file seeding approach with ephemeral real GitHub
repos created via the gh CLI. `helpers/github-fixtures.ts` creates a
single private repo per test run, pushes one branch per PR scenario,
and seeds commit statuses and comments as needed. The fake gh binary
now forwards unhandled calls (no fixture file present) to the real gh
so the daemon can query live GitHub data.

All 7 tests skip gracefully when gh auth is unavailable.

* refactor(app/e2e): address PR review feedback on pr-pane spec

- helpers/pr-pane: extract assertCheckPill helper so expectPrPaneCheckSummary
  is a flat 3-call sequence; remove branching on rendering shape
- helpers/pr-pane: drop .first() on explorer button and redundant toBeVisible
  before click; import getStateLabel from @/utils/pr-pane-data instead of
  duplicating the map
- pr-pane.spec.ts: move test.skip and test.setTimeout into beforeEach; replace
  positional IDX_* constants with workspaceByTitle Map keyed by PR title
- helpers/github-fixtures: add IssueSpec/GhIssueFixture and issues[] option;
  extract seedPr/seedIssue to satisfy complexity limit; make prs/issues optional
2026-05-05 19:58:55 +07:00
Mohamed Boudra
53e064c59c test(app/e2e): add desktop-updates spec covering update banner and daemon lifecycle (cluster G4) (#733)
* test(app/e2e): add desktop-updates spec covering update banner and daemon lifecycle (cluster G4)

Adds a new Electron-only E2E spec and companion helper module covering:
- Update callout renders with correct version and shows Installing… on click
- Daemon management toggle confirm dialog copy, cancel, and confirm flows
- Daemon status panel seeded from the real running E2E daemon (version, PID, log path)
- Stopping then re-enabling management observes a fresh PID from the stateful IPC mock

Exports E2E_PASEO_HOME from globalSetup so tests can read the paseo.pid lock file
and derive the daemon log path without hardcoding paths.

* fix(app/e2e): address PR review blockers on desktop-updates spec

Blocker 1 — ARIA for install button: replace index-based testId locators
in clickInstallUpdate and expectInstallInProgress with getByRole("button")
using the accessible name ("Install & restart" / "Installing...").

Blocker 2 — Electron dialog path: add dialog.ask to the mock bridge so
confirmDialog() hits the Electron code path instead of falling back to
window.confirm. The mock stores captured args on window.__capturedDialogCall;
interceptDaemonManagementConfirmDialog reads them via waitForFunction+evaluate.
Add confirmShouldAccept config flag so tests control accept/dismiss without
a Playwright dialog event. Update all daemon management tests to set the flag.

Also: console.warn on PID file read failure, comment explaining the no-Electron-
runner approach, rename dialog → dialogArgs at call sites.
2026-05-05 19:58:55 +07:00
Mohamed Boudra
4d102df2cf refactor(app/e2e): eliminate raw locators from all offending spec bodies (cluster #13) (#727)
Rewrites 7 spec files to use DSL helpers throughout — zero raw
page.locator/getByText/getByTestId in test() bodies. Adds 30+ new
helper primitives across 7 existing helper modules.
2026-05-05 19:58:55 +07:00
Mohamed Boudra
685e86cffc refactor(app/e2e): rewrite settings-navigation spec to zero raw locators (#726)
Replace 21 raw page.locator/getByTestId/getByText calls in the spec body
with named DSL operations in helpers/settings.ts. Each test body now reads
as prose: open settings, navigate to section, expect content.

New helpers: openCompactSettings, expectCompactSettingsList,
expectSettingsSidebarVisible/Hidden/Sections, goBackInSettings,
expectSettingsBackButton, clickSettingsBackToWorkspace,
verifyLegacyHostSettingsRedirect, openCompactSettingsHost,
expectHostSettingsUrl, expectAddHostMethodOptions, fillDirectHostUri,
expectDirectHostFormValues, expectDirectHostSslEnabled,
expectDirectHostUriValue/Hidden, expectDiagnosticsContent,
expectAboutContent, expectGeneralContent.

Export requireServerId from sidebar.ts so helpers encapsulate serverId
logic — spec has no direct env reads.

Also fix pre-existing typecheck error in use-keyboard-shortcuts.ts:
cast action.route to the expo-router Href type at call sites.
2026-05-05 19:58:55 +07:00
Mohamed Boudra
fedf155efd refactor(app): replace screen test slop batch 2 with proper coverage (#725)
Deletes four screen-level .test.tsx files (2600+ lines of vi.mock + JSDOM
slop) and replaces each with verified coverage:

- sessions-screen.test.tsx: covered by archive-tab.spec.ts which exercises
  the sessions screen via openSessions() with real agents
- workspace-draft-agent-tab.test.tsx: covered by new-workspace.spec.ts
  "redirects to optimistic draft tab before agent creation resolves"
- new-workspace-screen.test.tsx: covered by new-workspace.spec.ts (main
  submit, branch selection, PR selection, optimistic draft tab) plus
  new-workspace-picker-item.test.ts (pickerItemToCheckoutRequest)
- project-settings-screen.test.tsx: covered by project-config-form.test.ts
  (all round-trip string/array lifecycle semantics) and projects-settings.spec.ts
  (save flow with passthrough field preservation)

Extracts syncPickerPrAttachment from new-workspace-screen.tsx into a new
pure module new-workspace-picker-state.ts with 5 zero-mock unit tests
covering: initial PR selection, branch selection without change, PR
replacement, PR removal on branch switch, and no-duplicate guard.
2026-05-05 19:58:55 +07:00
Mohamed Boudra
634a9f1e9a refactor(app): replace 4 component test slop files with pure module extractions (#724)
Delete message-input.test.tsx, left-sidebar.test.tsx,
agent-stream-view.test.tsx, and agent-panel.test.tsx — all heavy
vi.mock + JSDOM + toHaveBeenCalledWith slop.

Coverage preserved by extracting the testable derivations:

- agent-stream-view-data.ts: isSameAssistantBlockGroup,
  getAssistantBlockSpacing, resolveInlineWorkingIndicatorItemId —
  14 pure unit tests, zero mocks
- message-input-state.ts: computeCanStartDictation (dictation readiness
  gate) — 7 pure unit tests, zero mocks

Remaining behaviors confirmed covered by existing E2E:
- left-sidebar subscription-while-hidden → sidebar-workspace.spec.ts
- agent-panel render isolation + archived agent store hygiene → archive-tab.spec.ts
- message-input attachment menu / submit icon → workspace-setup-streaming.spec.ts
2026-05-05 19:58:55 +07:00
Mohamed Boudra
79dac91a7a chore(app): clean up e2e helpers — delete duplicates, dead code, and redundant seeding (#723)
- Delete clickNewTabButton (duplicate of clickNewChat) and clickNewTerminalButton (duplicate of clickNewTerminal); update all call sites
- Rename clickTerminal → clickNewTerminal to match clickNewChat naming pattern
- Delete waitForLauncherPanel (deprecated no-op)
- Delete waitForAgentFinishUI and getToolCallCount (dead exports never imported)
- Remove ensureE2EStorageSeeded, assertE2EUsesSeededTestDaemon, and related helpers from app.ts — the paseoE2ESetup auto-fixture seeds via addInitScript on every navigation, making these redundant
- Simplify gotoAppShell to a one-liner; simplify gotoHome to use .or() instead of three-way if-else chain
- Strip try/catch self-heal from ensureHostSelected — the fixture guarantees preferences alignment, so the workaround is never needed
2026-05-05 19:58:55 +07:00
Mohamed Boudra
b2af00b037 test(app): triage desktop test files — delete 16-mock component slop, tighten attachment store (#721)
* test(app): triage desktop test files — delete 16-mock component slop, tighten attachment store assertion

Removes desktop-updates-section.test.tsx (16 internal mocks, JSDOM, toHaveBeenCalledWith chains on component renders).
Tightens desktop-attachment-store.test.ts: toHaveBeenCalled() → toHaveBeenCalledWith(attachment).

* refactor(app): extract daemon management toggle coordinator with unit tests

Fills the coverage gap left by deleting desktop-updates-section.test.tsx.
Extracts executeDaemonManagementToggle from useDaemonManagementToggle — pure
async coordinator with injected deps (no vi.mock). Unit tests verify the three
key invariants: settings persist before stop, stop is skipped when not
desktop-managed, and start/stop are invoked on enable/disable.
2026-05-05 19:58:55 +07:00
Mohamed Boudra
652793e00a refactor(app/e2e): migrate workspace-cwd spec to withWorkspace fixture (#722)
Drops the per-test manual boilerplate (connectWorkspaceSetupClient,
createTempGitRepo, seedProjectForWorkspaceSetup, openProject,
openHomeWithProject, navigateToWorkspaceViaSidebar, try/finally cleanup)
in favour of the withWorkspace fixture landed in #717.

Both tests — main checkout and worktree — verified green locally.
2026-05-05 19:58:55 +07:00
Mohamed Boudra
5da0423e48 refactor(app): extract composer-actions module with pure tests (#720)
Move composer cancel/queue/attachment/dispatch orchestration out of
composer.tsx into a React-free actions module. The module takes its
dependencies (send client, queue writer, stream writer, attachment
persister, image picker) as injected ports, so the new
composer-actions.test.ts can drive every action with inline fakes —
zero vi.mock of internal modules, zero JSDOM, zero React.

The old composer.test.tsx (heavy mocked component test) is removed
and replaced with composer-actions.test.ts (31 unit tests).

Also extract isWorkspaceAttachment / userAttachmentsOnly /
workspaceAttachmentToSubmitAttachment from composer-workspace-attachments.tsx
into a sibling .ts so the actions module (and composer-attachments.ts)
can use them without dragging React/RN/lucide into a pure test graph.
2026-05-05 19:58:55 +07:00
Mohamed Boudra
a978865f39 refactor(app): extract resolveAgentForm pure reducer from use-agent-form-state (#719)
Moves all preference-resolution logic into a pure `resolveAgentForm(state, action)` reducer
with a discriminated-union action type. The hook becomes a thin React container: it holds state
via `useReducer`, dispatches actions, and owns the hydration-ref timing logic.

Removes the `__private__` export and the vi.mock-heavy live test in favour of direct pure-function
unit tests (53 tests, zero vi.mock of internal modules).
2026-05-05 19:58:55 +07:00
Mohamed Boudra
1955fa6371 refactor(app): extract keyboard shortcut routing into a pure function (#718)
Replace 8-mock hook test (expo-router, layout, platform, navigation,
4 stores) with a pure unit test of the routing decision. The hook now
reads pathname/layout/key, calls routeKeyboardShortcut, and dispatches
the resulting ShortcutAction — no behaviour change.
2026-05-05 19:58:55 +07:00
Mohamed Boudra
5a63bc56f4 refactor(app): make use-agent-input-draft storage injectable (#716)
Extract DraftStorage interface and createAgentInputDraftCore factory
to a new use-agent-input-draft-core.ts. Pure helper functions (resolveDraftKey,
resolveEffectiveComposerModelId, etc.) move there too, breaking the test
file's transitive dependency on AsyncStorage and useAgentFormState.

The test now imports directly from the core module, uses a Map-backed
in-memory storage, and has zero vi.mock calls. Tests assert behavior
("save then load returns the same draft") rather than call counts.
2026-05-05 19:58:55 +07:00
Mohamed Boudra
cb4051f4ea feat(app/e2e): introduce withWorkspace fixture and DSL helpers (#717)
Adds a `withWorkspace` Playwright fixture plus composable helpers
(permissions, sidebar, composer, agent-stream, settings) so specs read
as user-level intent. Migrates workspace-lifecycle and settings-host-page
to the new DSL as proof.
2026-05-05 19:58:55 +07:00
Mohamed Boudra
0a84c613f7 test(app): tighten weak assertions in utils tests (#714)
Replace toBeDefined/toBeTruthy guards with concrete shape assertions
using toContainEqual(expect.objectContaining(...)) in diff-highlighter
tests, and toMatch(/^script-draft-\d+$/) for the ID check in
project-config-form tests.
2026-05-05 19:58:55 +07:00
Mohamed Boudra
6bd5d4c410 refactor(app): extract pr-pane derivations into pure utils and unit tests (#713)
Move getStateLabel and getActivityVerb out of pr-pane.tsx into pr-pane-data.ts
so they can be tested without JSDOM, React, or vi.mock. Add exhaustive unit tests
for both helpers alongside the existing mapPrPaneData/formatAge/deriveAvatarColor
coverage. Delete pr-pane.test.tsx — the component test was asserting on derived
label/icon strings that are now covered by pure function tests.
2026-05-05 19:58:55 +07:00
Mohamed Boudra
90c7e591e8 refactor(app): assert store state rather than mock call counts in store tests (#715)
Rewrites two store tests to verify observable state instead of implementation-level call counts.

navigation-active-workspace-store: the "persists" test now uses a functional in-memory AsyncStorage mock that retains written values across vi.resetModules(), then rehydrates a fresh store instance and asserts the resulting selection state matches what was written.

checkout-git-actions-store: removes toHaveBeenCalledWith/toHaveBeenCalledTimes assertions on client methods; replaces with getStatus() assertions that reflect the store's actual state after each action completes or fails.
2026-05-05 19:58:55 +07:00
Mohamed Boudra
fd30eafafc refactor(app): fix type-aware lint errors in UI components (#710) 2026-05-05 19:58:55 +07:00
Mohamed Boudra
8d3946c36c perf(cli): run CLI E2E tests in parallel (#708)
* perf(cli): run E2E tests in parallel via worker pool

The custom CLI test runner ran 35 tsx test files sequentially, making the
cli-tests CI job the longest in main CI (~17 minutes). Each test file
already isolates its own daemon (ephemeral port + tmp PASEO_HOME), so
parallelism was just gated by the runner.

Replace the sequential recursion with a fixed-size worker pool (default
concurrency=4 to match GitHub Actions standard runners; override via
PASEO_CLI_TEST_CONCURRENCY). Buffer per-test stdout/stderr and flush as
a contiguous block on completion so concurrent output stays readable,
and report per-test wall clock plus the five slowest tests.

Local wall clock drops from ~12-15 minutes serial to ~2:49 with
concurrency=4. The slowest single test (05-agent-run, 49s) is now the
floor; CI should land near 4-5 minutes.

* fix(cli-tests): deflake 30-chat under load and shard CI across 3 runners

`chat wait` reads the latest message id and then subscribes for newer
messages. Under CI load the subprocess takes >1s to bootstrap, so the
old test's 250ms head start before posting "second message" raced
against that read. When the post landed first, the subprocess saw the
second message as latest and timed out waiting for a newer one. Replace
the brittle delay with a post-and-race loop: every iteration posts a
fresh "second message" and races a 250ms tick against the wait promise,
so whichever message lands after the snapshot wakes wait deterministically.

CI was also slower than local (10.3min vs 2.8min). On 4-vCPU GHA
runners, 35 sequential-CPU-time of ~1850s caps wall clock around 8 min
even at concurrency=4. Shard the suite across 3 GHA runners via matrix
strategy. The runner now reads PASEO_CLI_TEST_SHARD/SHARD_TOTAL and
distributes files into buckets — known long-pole tests
(05/06/11/13/14-...) round-robin first so they spread across shards
instead of clustering by their numeric prefixes, then the remainder
fills in the reverse direction to balance light load.

Local run is unchanged (single shard by default). Expected per-shard
wall on CI: ~2:30 + setup ≈ ~4:30 total.
2026-05-05 19:58:55 +07:00
Mohamed Boudra
75247efbd5 refactor(agent-providers): fix type-aware lint errors in diagnostic-utils and generic-acp-agent (#705)
* refactor(agent-providers): add type guards and fix type-aware lint errors in diagnostic-utils, generic-acp-agent, and tool-call-detail-primitives

* fix(server): defer PTY onExit to allow pending data events to fire

On Linux, node-pty's onExit callback can arrive before the last buffered
PTY data chunk is delivered via onData. Wrapping finish() in setImmediate
gives libuv's I/O poll phase a chance to flush remaining PTY reads before
the promise resolves, preventing tail bytes from being silently dropped.

Fixes a flaky worktree-bootstrap test that reliably reproduced on Linux CI.

* unslop: remove dead guard, fix import order, trim comment

- generic-acp-agent: remove isNonEmptyStringArray and its guard; the
  constructor parameter is already typed [string, ...string[]] so the
  check can never fire
- provider-registry: move isNonEmptyStringArray below the import block
  instead of between two import groups
- worktree: condense 3-line comment to one line per project convention
2026-05-05 19:58:55 +07:00
Mohamed Boudra
660b4d3cec refactor(app): fix type-aware lint errors in voice, tabs store, host-connection, audio recorder (#706)
Clear all type-aware lint errors in four app state/runtime files:

- voice-context.tsx: bind runtime methods before passing to useSyncExternalStore
  and object spreads to satisfy the unbound-method rule
- workspace-tabs-store.ts: replace unsafe `as` casts with isPlainRecord type
  predicate + toObjectRecord; add coerceWorkspaceTabTarget to bridge unknown
  storage data to WorkspaceTabTarget without assertions
- host-connection.ts: same isPlainRecord/toObjectRecord pattern; replace
  String(record.x ?? "") calls with typeof guards to fix no-base-to-string
- use-audio-recorder.native.ts: construct real Blob instead of casting an
  object literal; restructure useEffect for consistent-return; drop await on
  void recorder.record(); use instanceof Error for message extraction; remove
  redundant Boolean() wrapping
2026-05-05 19:58:16 +07:00
Mohamed Boudra
c8274ba88a refactor(claude-agent): replace unsafe type assertions with type guards (#707)
Adds isObjectRecord, isUnknownArray, isChildProcessWithStreams, and
isImageMimeType type predicates to eliminate all no-unsafe-type-assertion
violations surfaced by typeAware lint mode. Fixes floating promise, adds
throw after exhaustive switch, and widens readCompactionMetadata to accept
unknown so callers need no casts.
2026-05-05 19:58:16 +07:00
Mohamed Boudra
8b54d27cbc refactor(server): add getErrorMessage helper and fix error-related type-aware lint errors (#704)
* refactor(server): add getErrorMessage helper and fix error-related type-aware lint errors in session.ts

* fix build: correct parseClientCapabilities type handling
2026-05-05 19:57:35 +07:00
Mohamed Boudra
d359bffed5 refactor(agent-providers): add toObjectRecord helper and fix type-aware lint errors in codex and claude agents (#699) 2026-05-05 19:57:35 +07:00
Mohamed Boudra
b2c88e4312 refactor(speech): add ONNX type augmentation and fix type-aware lint errors (#701) 2026-05-05 19:56:52 +07:00
Mohamed Boudra
ab9f07dd8a fix(app): make e2e setup an auto fixture so first-of-spec tests get setup (#702)
* fix(app): make e2e setup an auto fixture so it runs for the first test of every spec

The fixtures.ts beforeEach was declared at the top level of a non-test
fixture file. Playwright sometimes skipped it for the first test of a
subsequent spec when multiple specs ran in the same worker — that test
hit page.evaluate without the seed nonce or daemon registry in
localStorage and failed (then passed on retry, masking the bug behind
retries: 1). Reproduced locally by running sidebar-workspace then
startup-loading: the first startup-loading test got no fixture setup
and threw "Expected e2e seed nonce".

Move the setup into an `auto: true` fixture, the canonical Playwright
pattern for running setup for every test in a workspace. The afterEach
console-attachment is folded into the same fixture's teardown.

* refactor(server): simplify nullish handling in workspace-git-metadata

Drop the explicit `null` arg in `deriveProjectSlug(input.cwd, null)` —
the second parameter already defaults to `null`. Inline the
`repoName && repoName.length > 0` check in `parseGitHubRepoNameFromRemote`
since `pop()` on a non-empty array returns a non-empty string here, and
`|| null` covers the empty-string case directly.
2026-05-05 19:56:52 +07:00
Mohamed Boudra
f974812dfc refactor(relay): fix type-aware lint errors in WebSocket and crypto handling (#698) 2026-05-05 19:56:52 +07:00
Mohamed Boudra
400934f19c schedule: add paseo schedule update to edit schedules in place (#694)
* schedule: add `paseo schedule update` to edit schedules in place

Editing a schedule today requires delete+recreate, losing run history
and the schedule id. This adds an additive RPC and CLI command that
patches name, prompt, cadence, new-agent target fields, max-runs, and
expires-in without touching runs or in-flight executions. nextRunAt is
recomputed only when the cadence actually changes.

* schedule(cli): share cadence flag parser between create and update

Both paths were turning --every/--cron into a ScheduleCadence with
near-identical code. Extract `parseCadenceFromFlags` so the literals
and the exclusivity check live in one place; create wraps it to
require a value, update lets it stay optional.
2026-05-05 19:56:52 +07:00
Mohamed Boudra
82cf11aaae fix(server): derive non-GitHub project display names from remote owner/repo (#697)
Reconciliation overwrote correctly-set project display names with the
directory name for non-GitHub remotes (e.g. gitlab.com/acme/app), because
buildWorkspaceGitMetadataFromSnapshot only handled GitHub URLs while the
registry-model layer used the more general deriveProjectGroupingKey path.
Reuse that path here so both layers agree on the owner/repo display name.
2026-05-05 19:56:52 +07:00
Mohamed Boudra
3bf8d483d3 refactor(app): remove unnecessary String() and Boolean() conversions from type-aware lint fixes (#696) 2026-05-05 19:56:52 +07:00
Mohamed Boudra
84d822450e refactor(server): remove unnecessary String() conversions from type-aware lint fixes (#695) 2026-05-05 19:56:52 +07:00
Mohamed Boudra
cccccca18e refactor(server): remove redundant null from unknown union types (#693) 2026-05-05 19:56:52 +07:00
Mohamed Boudra
936deaa869 refactor(app): remove redundant type constituents from type definitions (#692) 2026-05-05 19:56:52 +07:00
Mohamed Boudra
946152d820 refactor(server): replace JSON.parse type assertions with Zod validation (#691)
Replace unsafe 'JSON.parse(content) as Type' patterns with proper Zod schema
validation. This fixes type-aware lint errors and provides runtime safety.

- pid-lock.ts: Add pidLockInfoSchema and parsePidLockInfo helper
- package-version.ts: Add packageJsonSchema and parsePackageJson helper
- relay-transport.ts: Add isRecord type guard to avoid unsafe type assertion
2026-05-05 19:56:51 +07:00
Mathias Kurz
44d13919d7 Feat/open projects config to any (#681)
* feat(server): derive owner/repo display name for any remote host

Generalize deriveProjectGroupingName so any remote:<host>/<segments...>
project key returns the last two path segments (owner/repo) instead of
just the trailing segment. Path-fallback for non-remote keys is unchanged.

Brings GitLab, Gitea, Bitbucket, and self-hosted remotes to parity with
the prior github.com-only behavior — no separate special-case needed.

* feat(app): show projects from any git remote in Projects settings

Remove the isSupportedProjectKey filter so workspaces with non-GitHub
remotes (GitLab, Gitea, Bitbucket, self-hosted, ssh-style) appear in the
Projects list and route to the project settings screen. The daemon RPCs,
config schema, and registry were already host-agnostic; this lifts a
client-side filter that hid them.

Also remove the now-vestigial hiddenUnsupportedRemoteCount field from
ProjectSummary, BuildProjectsResult, and UseProjectsResult — once the
filter is gone, the count is always zero and the field is dead state.
This is an internal app-package type, not a wire schema, so deletion is
safe.

* chore(app): simplify Projects empty state to "No projects yet"

Drop the "Non-GitHub remote projects aren't supported yet" empty-state
branch — it can no longer fire now that any git remote is shown. The
empty state is unconditional now.

* test(app): cover non-GitHub remote in projects-settings e2e

Add a fixture that creates a temp git repo with origin pointing at a
gitlab.com URL and exercises the same paseo.json read/edit/save flow
already covered for local repos. Verifies the project surfaces with the
"acme/app" display name and that the round-trip persists correctly.

Extracted the remote-setup branch in createTempGitRepo into a small
configureRemote helper to keep the main function under the cyclomatic
complexity limit.

---------

Co-authored-by: Mathias Kurz <mkurz@stamus-networks.com>
2026-05-05 19:56:51 +07:00
Mohamed Boudra
2b6d7d4ec2 schedule: fire --every now by default, add run-once for cron-style triggers (#689)
Interval schedules used to wait the full interval before their first run, which
made `--every 1d` feel broken for fresh schedules. Now `--every` fires
immediately on creation; `--cron` keeps waiting for the next slot. `--no-run-now`
and `--run-now` override the defaults, and the parser rejects redundant combos.

Adds `paseo schedule run-once <id>` to manually trigger a single run without
advancing cadence or recomputing completion. The new `runOnCreate` field and
`schedule/run-once` RPC are additive on the WebSocket schema.
2026-05-05 19:56:51 +07:00
Mohamed Boudra
f11aea3223 mcp(create_agent): validate mode and refuse silent cross-provider inheritance (#688)
The MCP create_agent tool inherited the parent agent's mode regardless of
provider, so a Claude parent in bypassPermissions spawning an opencode
child would feed an invalid mode to opencode and the new agent would die
in error state. The fix:

- Add an optional mode field to the agent-to-agent input schema.
- Validate any explicit mode against the target provider's available
  modes; throw with the list when invalid.
- Inherit the caller's mode only when both sides use the same provider;
  otherwise refuse with a helpful error listing the target's modes.
2026-05-05 19:56:51 +07:00
Mohamed Boudra
2b75df6132 refactor(relay): remove unnecessary awaits from synchronous crypto functions (#687) 2026-05-05 19:56:51 +07:00
Mohamed Boudra
b45f0a9ad1 feat(cli): paseo worktree create with MCP parity (#686)
Mirrors the MCP create_worktree tool's three modes (branch-off,
checkout-branch, checkout-pr) against the daemon's existing
createPaseoWorktree RPC, so CLI users (especially over --host) can
provision worktrees in the paseo-managed location.
2026-05-05 19:56:51 +07:00
Mohamed Boudra
4b260eb516 fix(cli): update schedule provider/mode error message substring in test
The error message gained a `--mode` mention when the schedule mode flag
landed; the existing CLI integration test still asserted on the old
exact-prefix substring and broke main CI for all PR builds.
2026-05-05 19:56:51 +07:00
Mohamed Boudra
14c33aad0b cli(schedule): require --cwd when --host is set (#685)
process.cwd() is the local path and won't exist on a remote daemon, so
fail fast client-side instead of running the schedule in a wrong dir.
2026-05-05 19:56:51 +07:00
Mohamed Boudra
e22b497a08 voice: quieter thinking tone, log cleanup, and small ui polish
- Add scripts/lower-thinking-tone.mjs to scale the thinking-tone PCM
  amplitude in-place; apply it at 0.1 so the cue is a soft indicator
  instead of triggering the VAD via mic feedback.
- Drop the "Received first voice_audio_chunk" log that re-fires every
  summary window because the chunk counter is reset.
- Match Spoke message icon and label sizing to the standard tool-call
  badge.
- Append a spoken-input instruction so voice replies route through the
  speak tool.
2026-05-05 19:56:51 +07:00
Mohamed Boudra
91865ba66e Switch voice turn controller to streaming transcription 2026-05-05 19:56:51 +07:00
Mohamed Boudra
4d31cd4013 fix(opencode): forward provider retries instead of swallowing them
OpenCode subproviders (e.g. opencode/kimi-k2.6 on OpenCode Zen) emit
session.status:retry events with messages like "Internal server error"
when the upstream provider returns 5xx. opencode itself retries
indefinitely with backoff and never emits a terminal event for these.

Previously the adapter only surfaced retry messages on a small allowlist
of "fatal" tokens (insufficient balance, invalid api key, etc.) and
silently dropped everything else. The agent appeared hung from the
user's perspective — no message, no spinner update, nothing — until the
upstream eventually recovered or the user manually interrupted.

Forward every session.status:retry as a non-terminal timeline error
item so the user can see what opencode is doing, mirroring opencode's
own TUI. Drop the fatal-token allowlist: classifying which retries are
"really" terminal is opencode's job, and synthesizing turn_failed for
ones we guess at is misleading anyway because opencode keeps spending
upstream while we tell the user the agent is done.

Also a small design pass on the timeline error rendering:
- drop the redundant "Agent error" prefix (the message is descriptive)
- drop the colored box background (visual weight too high for a retry)
- align the icon vertically to the first text line (height+center)
- make the message text selectable so users can copy errors
2026-05-05 19:56:51 +07:00
Mohamed Boudra
0775f59a6f Add --mode to schedule and loop CLI, default background runs to unattended mode
Plumb --mode/--verify-mode through paseo schedule create and paseo loop run.
Mark each provider's unattended mode (claude bypassPermissions, codex/opencode
full-access, copilot autopilot) so loop and schedule services pick it by
default when no explicit modeId is given.
2026-05-05 19:56:26 +07:00
Mohamed Boudra
5feaa7a101 Type CLI command host/json options at the source
The CommandOptions interface in packages/cli/src/output/with-output.ts had
an open `[key: string]: unknown` indexer, so every consumer of the global
--host and --json options had to cast `options.host as string | undefined`
at the call site. Add `host?: string` and `json?: boolean` to the interface
and remove 29 redundant casts across 12 command files.
2026-05-05 19:56:26 +07:00
Mohamed Boudra
4cd9e76bd2 Remove unnecessary type assertions across codebase
Add oxlint-tsgolint and configure typescript/no-unnecessary-type-assertion
to flag redundant `!` and `as Foo` casts. Type-aware mode is left off by
default to keep `npm run lint` fast; the rule sits configured for when we
turn type-aware on intentionally. Auto-fix removed ~283 redundant casts;
two manual touch-ups: a real tsgolint false positive in split-container.tsx
and a stale ChildProcess import after a double-cast collapsed.
2026-05-05 19:56:26 +07:00
Mohamed Boudra
78fe3e4df3 Drop subagent task notifications from parent timeline
Subagent task_notification system messages arrive without
parent_tool_use_id but with tool_use_id pointing at the parent's Task
call, so they slip past the sidechain router and render as top-level
"Task Notification" rows in the parent's timeline. Skip them when the
referenced tool_use is a Task call; parent-level background bash
notifications still flow through.
2026-05-05 19:56:26 +07:00
Mohamed Boudra
72e7c7e1ed Replace fictional fastlane action with Spaceship build-processing poll
The Fastfile called wait_for_build_processing_to_be_complete, which
isn't a built-in fastlane action and isn't published as a plugin
anywhere on rubygems. The submit_review lane has therefore failed
on every release that reached it (v0.1.65-beta.1, v0.1.66, v0.1.67).

Replace it with a direct Spaceship::ConnectAPI poll: fetch the build
matching the latest TestFlight build number, wait until its
processing_state is VALID, then hand off to deliver. 30-minute cap
with a clear failure message on INVALID/FAILED or timeout. Spaceship
ships with fastlane so no plugin or extra gem is required.

Lands in the next release; v0.1.67 needs a manual review submission
from App Store Connect.
2026-05-05 19:56:26 +07:00
github-actions[bot]
b0c6631979 fix: update lockfile signatures and Nix hash 2026-05-05 12:41:58 +00:00
Mohamed Boudra
1e5e17f000 chore(release): cut 0.1.69 2026-05-05 19:40:12 +07:00
Mohamed Boudra
20de118373 Add changelog for 0.1.69 2026-05-05 19:39:18 +07:00
Mohamed Boudra
fd4e26ca9b Align script TypeScript lib with server build 2026-05-05 18:54:29 +07:00
Somasundaram Ayyappan
f89e5604d2 fix: normalize Claude AskUserQuestion answers (#755) (#760)
Translate Paseo's header-keyed AskUserQuestion answers into the full question-text keys Claude expects before resolving question permissions.

Also preserve the original question payload when UI callbacks return an answers-only updatedInput, and lock the behavior down with provider-flow regression coverage in claude-agent tests.
2026-05-05 19:50:34 +08:00
Mohamed Boudra
ca3e55813e Keep daemon worker supervision explicit 2026-05-05 18:48:07 +07:00
Mohamed Boudra
3fad128d02 Restart daemon worker crashes in production 2026-05-05 18:42:21 +07:00
Mohamed Boudra
0cb9da17cd Daemon hardening epic 2026-05-05 18:21:10 +07:00
github-actions[bot]
1571f002c7 fix: update lockfile signatures and Nix hash 2026-05-05 09:47:45 +00:00
Mohamed Boudra
84478a2dba chore(release): cut 0.1.68 2026-05-05 16:46:10 +07:00
Mohamed Boudra
93065b20f7 Add changelog for 0.1.68 2026-05-05 16:45:16 +07:00
Mohamed Boudra
bb8762e122 Fix desktop settings first-launch race 2026-05-05 16:43:38 +07:00
Mohamed Boudra
15a2e3bdcb chore(release): cut 0.1.67 2026-05-04 01:43:24 +07:00
Mohamed Boudra
64ba05cea5 Add v0.1.67 changelog entry 2026-05-04 01:42:23 +07:00
Mohamed Boudra
1521ceb381 Surface desktop daemon gate failures instead of silent no-op
The async settings gate added in f0d96f8e called
shouldStartDaemon() inside Promise.resolve(...).then(...) with no
catch. If loadDesktopSettings() rejects (corrupted JSON, FS error),
the daemon never starts, no error renders on the splash, and the
retry button repeats the same silent path.

Add a recordError method to DaemonStartService and an onGateError
callback to startDaemonIfGateAllows / startHostRuntimeBootstrap.
_layout.tsx wires it through so a gate rejection populates
lastError, the splash shows the failure, and retry has signal.
2026-05-04 01:40:47 +07:00
Mohamed Boudra
4338f5b46c Fix workspace reconnect playwright tests after toast move
Test #1 was asserting agent-reconnecting-toast on a workspace with no
agents — but commit 1daa1314 moved the reconnect indicator from the
workspace banner into the per-agent panel toast. Create an idle agent
and navigate to it so the agent panel mounts before dropping the
daemon gate.

Test #2 regex still pinned the old "Connecting to localhost" copy;
commit c534c857 tightened it to just "Connecting" with the hostname in
the description. Match the new title plus the existing offline copies.
2026-05-04 01:18:37 +07:00
Mohamed Boudra
e8a64fb569 ci: build server before desktop tests
f0d96f8e added packages/desktop/src/daemon/daemon-manager.test.ts,
which calls vi.mock("@getpaseo/server", ...). Vite's module
resolution still needs to find the package's main entry, so
desktop CI must build the server (and its dependency chain) before
running the tests. Mirrors the prebuild steps used by typecheck,
server-tests, and playwright.
2026-05-04 00:40:38 +07:00
Mohamed Boudra
cf1f849b68 Update MCP create_agent test to match async branch auto-name
7c85341a moved first-agent branch auto-naming out of the worktree
service (sync slugify) into the workflow layer (async LLM call).
The MCP test stubs createPaseoWorktree, bypassing the workflow,
so the placeholder mnemonic branch is never renamed at this layer.
Replace the obsolete sync-slugify outcome assertion with a
placeholder-presence check; the auto-name logic itself is covered
in worktree-branch-name-generator.test.ts.
2026-05-04 00:31:33 +07:00
Mohamed Boudra
bc0886ad7c Merge branch 'main' of github.com:getpaseo/paseo 2026-05-04 00:16:08 +07:00
Mohamed Boudra
12fcfe53eb Make structured response generation ephemeral end-to-end
Plumbs persistSession through the structured response pipeline so
metadata, branch name, commit message, and PR text generators no
longer leave provider-side artifacts behind:

- Codex: passes ephemeral: true on thread/start (no rollout file)
- OpenCode: deletes the session via session.delete on close
- Claude: forwards persistSession: false to the SDK query

Adds a real-credentials e2e test per provider that asserts the
generated title is applied AND no persistence artifact is produced
(rollout for Codex, project JSONL for Claude, leftover session for
OpenCode).
2026-05-04 00:10:50 +07:00
github-actions[bot]
a198a33ff5 fix: update lockfile signatures and Nix hash 2026-05-03 16:24:36 +00:00
Mohamed Boudra
7c85341a42 Fix worktree creation and archive flow
Server: drop the synchronous branch-name generator dep, use the
worktree slug as the initial branch, and move first-agent branch
auto-naming to an async post-creation step backed by a structured
LLM call. Guards against renaming when the placeholder branch has
already been changed.

App: make workspace and worktree archive optimistic, rolling back
the local hide if the daemon call fails, and suppress incoming
workspace upserts while an archive is pending.
2026-05-03 23:22:54 +07:00
Mohamed Boudra
c534c857a4 Tighten workspace host connecting copy 2026-05-03 23:01:29 +07:00
Mohamed Boudra
6d8c7c4d2d Fix desktop settings legacy override 2026-05-03 22:57:33 +07:00
Mohamed Boudra
f0d96f8e5a Fix built-in daemon management toggle 2026-05-03 22:02:40 +07:00
Mohamed Boudra
f8d4758e6b Run iOS Fastlane review-submit on macOS
Linux EAS runners don't ship Ruby/Bundler, so `bundle install`
fails with "bundle: command not found". macOS workflow runners
have Ruby 3.2 + Fastlane preinstalled, so switching this job to
runs_on: macos-medium gets us there without a manual install step.
2026-05-03 21:27:24 +07:00
github-actions[bot]
09b498da61 fix: update lockfile signatures and Nix hash 2026-05-03 13:50:32 +00:00
Mohamed Boudra
221b6665a0 chore(release): cut 0.1.66 2026-05-03 20:48:53 +07:00
Mohamed Boudra
94dd36a4c9 Fix EAS Fastlane working_directory doubling
The eas/checkout step already cd's into packages/app (where eas.json
lives), so the explicit `working_directory: ./packages/app` on the
Fastlane steps resolved to /home/expo/workingdir/build/packages/app/packages/app
and failed every release with ENOENT.
2026-05-03 20:44:01 +07:00
Mohamed Boudra
84c1ff560f Add 0.1.66 changelog entry 2026-05-03 20:39:26 +07:00
Mohamed Boudra
b7f9092e93 Fix terminal renderer remount loading 2026-05-03 20:38:12 +07:00
Mohamed Boudra
ade05607d2 fix(app): latch startup store readiness 2026-05-03 20:15:19 +07:00
Mohamed Boudra
6d13796b2d Move workspace gate background and root layout off useUnistyles
The workspace gate (loading/unreachable/missing) used surfaceWorkspace,
which clashed with the panels' surface0. Switch the gate shell to
surface0 so it matches the agent panel.

Also remove three hot-path useUnistyles() calls from _layout.tsx that
were re-rendering the whole app on every theme/breakpoint/insets change:
RootLayout and AppContainer move to StyleSheet.create, and the desktop
window-controls effect lifts into a small leaf component.
2026-05-03 19:49:10 +07:00
Mohamed Boudra
191cea47a0 Merge branch 'main' of github.com:getpaseo/paseo 2026-05-03 19:25:23 +07:00
Mohamed Boudra
0aa4868b31 Fix terminal DSR replies 2026-05-03 19:24:30 +07:00
github-actions[bot]
e7016ef6b8 fix: update lockfile signatures and Nix hash 2026-05-03 12:19:32 +00:00
Mohamed Boudra
4332eca9ca Shorten agent initialization timeout 2026-05-03 19:12:36 +07:00
Mohamed Boudra
fc31d31493 Disable dev client runtime metrics logging 2026-05-03 19:11:25 +07:00
Mohamed Boudra
8ba71cc189 Preserve live markdown block newlines 2026-05-03 19:09:24 +07:00
2211 changed files with 289421 additions and 94110 deletions

View File

@@ -0,0 +1,11 @@
---
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 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
---
# Release beta
Read `docs/release.md` in the Paseo repo and follow the **Beta flow** section end-to-end. Run the **Beta release** completion checklist at the bottom of that doc.
Key rules the doc enforces — each beta updates an in-place `CHANGELOG.md` entry (`## X.Y.Z-beta.N`) that gets overwritten at promotion (never leave a stale `-beta.N` heading behind), and betas publish npm only with the explicit `beta` dist-tag.

View File

@@ -0,0 +1,11 @@
---
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".
user-invocable: true
---
# Release stable
Read `docs/release.md` in the Paseo repo and follow the **Standard release (patch)** flow if cutting fresh, or the **Beta flow** promotion step if promoting an existing beta. Run the **Stable release (or promotion)** completion checklist at the bottom of that doc.
The doc covers the changelog (required for stable), the pre-release sanity check (required for stable), and the post-release babysit pattern. Don't skip steps.

1
.claude/skills/release-beta Symbolic link
View File

@@ -0,0 +1 @@
../../.agents/skills/release-beta

View File

@@ -0,0 +1 @@
../../.agents/skills/release-stable

123
.github/ISSUE_TEMPLATE/bug-report.yml vendored Normal file
View File

@@ -0,0 +1,123 @@
name: Bug report
description: Something is broken or doesn't behave the way it should.
title: "bug: "
labels: ["bug"]
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
- try to reproduce on the latest version
- if it's a UI bug, capture a screenshot or short video. text descriptions of UI bugs almost always lose detail.
- type: textarea
id: description
attributes:
label: What's broken
description: What happened, and what did you expect to happen instead?
placeholder: |
I tried to X, expected Y, got Z.
validations:
required: true
- type: textarea
id: repro
attributes:
label: Steps to reproduce
description: The shortest sequence that triggers the bug. If you can't reproduce on demand, say so.
placeholder: |
1. Open the desktop app
2. Pair a daemon
3. Click X
4. ...
validations:
required: true
- type: dropdown
id: surface
attributes:
label: Where did this happen
description: The surface you saw the bug on. Pick the closest match.
options:
- iOS app
- Android app
- Web (browser)
- Desktop (Electron)
- CLI
- Daemon
- Other
validations:
required: true
- type: input
id: paseo-version
attributes:
label: Paseo version
description: Settings → About in the app, or `paseo --version` from the CLI.
placeholder: "0.1.71"
validations:
required: true
- type: input
id: os-version
attributes:
label: OS version
description: Only relevant for desktop, CLI, or daemon issues. Skip for mobile or web.
placeholder: "macOS 15.2, Windows 11, Ubuntu 24.04"
- type: dropdown
id: provider
attributes:
label: Agent provider
description: If the bug involves a specific agent provider, pick which one.
options:
- Not relevant
- Claude Code
- Codex
- OpenCode
- Custom provider
- type: textarea
id: provider-details
attributes:
label: Provider configuration
description: |
If the bug involves an agent, what version are you on and what API are you using? Provider behavior changes a lot across versions and API backends.
placeholder: |
Claude Code v1.2.3 with Anthropic API
Codex CLI v0.5.0 with OpenAI API
OpenCode v0.3.1
(or paste the relevant section of ~/.paseo/config.json for custom providers)
- type: textarea
id: logs
attributes:
label: Logs
description: |
Paste relevant log output. Strongly preferred for crashes and daemon issues.
- **Daemon log:** `~/.paseo/daemon.log` (override with `$PASEO_HOME`)
- **Electron log (macOS):** `~/Library/Logs/Paseo/main.log`
- **Electron log (Windows):** `%APPDATA%\Paseo\logs\main.log`
- **Electron log (Linux):** `~/.config/Paseo/logs/main.log`
Paste the **full log around the time of the bug**, not a summary. If you used an AI to investigate, paste the raw log it read, not the AI's interpretation. AI summaries skew the signal and waste my time.
render: text
- type: textarea
id: screenshots
attributes:
label: Screenshots or video
description: |
**Required for UI bugs.** Drag and drop directly into this field. Short videos beat screenshots for anything involving interaction or animation.
- type: markdown
attributes:
value: |
---
**A note on AI-assisted reports.** Using an agent to gather information (logs, repro steps, version checks) is fine and useful. Using an agent to *diagnose* the bug and then submitting only that diagnosis is not. Agents routinely correlate adjacent log lines as cause-and-effect when they aren't related, and once a report is filtered through an AI summary I lose the signal I need to actually fix the bug. Paste the raw inputs.

5
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: Discord
url: https://discord.gg/jz8T2uahpH
about: Urgent or blocking issues, quick questions, sharing a video of a bug, or anything that's better as a chat.

View File

@@ -0,0 +1,43 @@
name: Feature request
description: Propose a new feature or a change to existing behavior.
title: "feat: "
labels: ["enhancement"]
body:
- type: markdown
attributes:
value: |
Paseo is opinionated and maintained by one person. Feature requests are welcome, but they get evaluated against product fit, not just usefulness, and the bar is whether the change keeps the product lean enough for one person to maintain.
Big ideas are better discussed in [Discord](https://discord.gg/jz8T2uahpH) first. And please don't open a feature request and a PR at the same time, get alignment on the idea before writing code.
- type: checkboxes
id: prior-search
attributes:
label: Prior search
options:
- label: I searched existing issues and discussions, and this isn't already proposed.
required: true
- type: textarea
id: problem
attributes:
label: What's the problem
description: What are you actually trying to do, and why is the current behavior in the way?
placeholder: |
When I'm doing X, I want to Y, but Paseo currently Z.
validations:
required: true
- type: textarea
id: proposal
attributes:
label: What would solve it
description: A rough sketch of the change. Mockups, screenshots from other apps, or a short video are very welcome, especially for UI proposals.
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives you considered
description: Optional. What else did you try, and why doesn't it work?

43
.github/PULL_REQUEST_TEMPLATE.md vendored Normal file
View File

@@ -0,0 +1,43 @@
<!--
Please follow this template. The PR template applies whether you opened the PR via the web UI, `gh pr create`, or any other tool.
If you're fixing an objective bug or a small focused issue, this should be quick. Big PRs without a prior issue or design discussion are likely to be closed or scoped down. See CONTRIBUTING.md.
-->
### Linked issue
Closes #
<!-- Bug fixes and behavior changes should reference an issue. Pure docs and refactors can skip this. -->
### Type of change
- [ ] Bug fix
- [ ] New feature (with prior issue + design alignment)
- [ ] Refactor / code improvement
- [ ] Docs
### What does this PR do
<!-- A short description of the change in your own words. What was wrong, what you changed, why it works. If you can't explain this briefly, the PR is probably too big. -->
### How did you verify it
<!--
This is the section I read most carefully. I need to see that *you* tested this, not that the diff looks plausible.
- For UI changes: a screenshot or short video on every affected platform (mobile, web, desktop). UI claims without visual proof are not enough.
- For behavior changes: the actual steps you ran, and what you observed.
- For bug fixes: how you reproduced the bug before, and confirmed it's fixed after.
AI-generated PR descriptions are fine in principle. AI-generated *verification claims* with no actual testing behind them are not, and they're easy to spot.
-->
### Checklist
- [ ] One focused change. Unrelated cleanups split out.
- [ ] `npm run typecheck` passes
- [ ] `npm run lint` passes
- [ ] `npm run format` ran (Biome)
- [ ] UI changes include screenshots or video for every affected platform
- [ ] Tests added or updated where it made sense

View File

@@ -5,11 +5,25 @@ on:
branches: [main]
pull_request:
branches: [main]
merge_group:
workflow_dispatch:
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
# CI does not use the CUDA execution provider, and the onnxruntime-node
# postinstall download from NuGet is large enough to make npm ci flaky.
ONNXRUNTIME_NODE_INSTALL: skip
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
@@ -19,13 +33,15 @@ jobs:
cache: "npm"
- name: Install dependencies
run: npm install
run: npm ci
- name: Check formatting
run: npx oxfmt --check .
lint:
runs-on: ubuntu-latest
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
steps:
- uses: actions/checkout@v4
@@ -35,13 +51,21 @@ jobs:
cache: "npm"
- name: Install dependencies
run: npm install
run: npm ci
- name: Lint lockfile
run: npx --yes lockfile-lint --path package-lock.json --type npm --allowed-hosts npm --validate-https --validate-integrity
- name: Verify dependency signatures
run: npm audit signatures
- name: Lint
run: npm run lint
typecheck:
runs-on: ubuntu-latest
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
steps:
- uses: actions/checkout@v4
@@ -51,22 +75,29 @@ jobs:
cache: "npm"
- name: Install dependencies
run: npm install
run: npm ci
- name: Build highlight dependency
run: npm run build --workspace=@getpaseo/highlight
- name: Build relay dependency
run: npm run build --workspace=@getpaseo/relay
- name: Build server dependency
run: npm run build --workspace=@getpaseo/server
- name: Build server stack
run: npm run build:server
- name: Typecheck all packages
run: npm run typecheck
- name: Verify public package contents
run: |
npm pack --dry-run --ignore-scripts --workspace=@getpaseo/protocol
npm pack --dry-run --ignore-scripts --workspace=@getpaseo/client
npm pack --dry-run --ignore-scripts --workspace=@getpaseo/server
server-tests:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
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:
@@ -81,62 +112,20 @@ jobs:
run: git fetch --no-tags origin main:refs/remotes/origin/main
- name: Install dependencies
run: npm install
run: npm ci
- name: Build highlight dependency
run: npm run build --workspace=@getpaseo/highlight
- name: Install agent CLIs for provider tests
run: npm install -g @anthropic-ai/claude-code opencode-ai
- name: Build relay dependency
run: npm run build --workspace=@getpaseo/relay
- name: Build server dependencies
run: npm run build:server-deps
- name: Run server tests
run: npm run test --workspace=@getpaseo/server
env:
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
server-tests-windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
- name: Install dependencies
run: npm install
- name: Build highlight dependency
run: npm run build --workspace=@getpaseo/highlight
- name: Build relay dependency
run: npm run build --workspace=@getpaseo/relay
- name: Run Windows-critical server tests
working-directory: packages/server
run: >
npx vitest run
src/utils/executable.test.ts
src/utils/spawn.launch-regression.test.ts
src/utils/spawn.percent-escape.test.ts
src/utils/spawn.test.ts
src/utils/process-tree.test.ts
src/utils/run-git-command.test.ts
src/utils/checkout-git-rev-parse.test.ts
src/terminal/worker-terminal-manager.test.ts
src/server/agent/provider-registry.test.ts
src/server/agent/provider-launch-config.test.ts
src/server/agent/provider-snapshot-manager.test.ts
src/server/agent/providers/claude-agent.spawn.test.ts
src/server/agent/providers/provider-windows-launch.test.ts
src/server/agent/providers/provider-availability.test.ts
src/server/workspace-registry-model.test.ts
src/server/persisted-config.test.ts
src/server/bootstrap-provider-availability.test.ts
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
desktop-tests:
strategy:
@@ -152,14 +141,46 @@ jobs:
node-version: "22"
cache: "npm"
- name: Install dependencies
run: npm install
- name: Install dependencies with Electron retry
if: runner.os != 'Windows'
run: |
for attempt in 1 2 3; do
if npm ci; then
exit 0
else
exit_code=$?
fi
if [ "$attempt" -eq 3 ]; then
exit $exit_code
fi
sleep $((attempt * 20))
done
- name: Install dependencies with Electron retry
if: runner.os == 'Windows'
shell: pwsh
run: |
for ($attempt = 1; $attempt -le 3; $attempt++) {
npm ci
if ($LASTEXITCODE -eq 0) {
exit 0
}
if ($attempt -eq 3) {
exit $LASTEXITCODE
}
Start-Sleep -Seconds (20 * $attempt)
}
- 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
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
steps:
- uses: actions/checkout@v4
@@ -168,20 +189,34 @@ jobs:
node-version: "22"
cache: "npm"
- name: Install dependencies
run: npm install
- name: Install dependencies with retry
run: |
for attempt in 1 2 3; do
if npm ci; then
exit 0
else
exit_code=$?
fi
if [ "$attempt" -eq 3 ]; then
exit $exit_code
fi
sleep $((attempt * 20))
done
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
timeout-minutes: 10
run: npx playwright install chromium
- name: Build highlight dependency
run: npm run build --workspace=@getpaseo/highlight
- name: Build app dependencies
run: npm run build:app-deps
- name: Run app unit tests
run: npm run test --workspace=@getpaseo/app
playwright:
sdk-tests:
runs-on: ubuntu-latest
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
steps:
- uses: actions/checkout@v4
@@ -191,22 +226,58 @@ jobs:
cache: "npm"
- name: Install dependencies
run: npm install
run: npm ci
- name: Build client dependencies
run: npm run build:client
- name: Run protocol tests
run: npm run test --workspace=@getpaseo/protocol
- name: Run client tests
run: npm run test --workspace=@getpaseo/client
- name: Typecheck client examples
run: npm run typecheck:examples --workspace=@getpaseo/client
playwright:
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: |
for attempt in 1 2 3; do
if npm ci; then
exit 0
else
exit_code=$?
fi
if [ "$attempt" -eq 3 ]; then
exit $exit_code
fi
sleep $((attempt * 20))
done
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
timeout-minutes: 10
run: npx playwright install chromium
- name: Build highlight dependency
run: npm run build --workspace=@getpaseo/highlight
- name: Build app dependencies
run: npm run build:app-deps
- name: Build relay dependency
run: npm run build --workspace=@getpaseo/relay
- name: Build server dependency
run: npm run build --workspace=@getpaseo/server
- name: Build server stack
run: npm run build:server
- name: Install agent CLIs for provider tests
run: npm install -g @openai/codex@0.105.0 opencode-ai
run: npm 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
@@ -225,6 +296,8 @@ jobs:
relay-tests:
runs-on: ubuntu-latest
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
steps:
- uses: actions/checkout@v4
@@ -234,16 +307,23 @@ jobs:
cache: "npm"
- name: Install dependencies
run: npm install
run: npm ci
- name: Build relay
run: npm run build --workspace=@getpaseo/relay
run: npm run build:relay
- name: Run relay tests
run: npm run test --workspace=@getpaseo/relay
cli-tests:
strategy:
fail-fast: false
matrix:
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
@@ -253,13 +333,10 @@ jobs:
cache: "npm"
- name: Install dependencies
run: npm install
run: npm ci
- name: Install agent CLIs for provider tests
run: npm install -g @openai/codex@0.105.0 opencode-ai
- name: Build highlight dependency
run: npm run build --workspace=@getpaseo/highlight
run: npm install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
- name: Run CLI tests
run: npm run test --workspace=@getpaseo/cli
@@ -267,3 +344,5 @@ jobs:
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: "0"
PASEO_DICTATION_ENABLED: "0"
PASEO_VOICE_MODE_ENABLED: "0"
PASEO_CLI_TEST_SHARD: ${{ matrix.shard }}
PASEO_CLI_TEST_SHARD_TOTAL: "3"

View File

@@ -28,8 +28,8 @@ jobs:
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Build highlight dependency
run: npm run build --workspace=@getpaseo/highlight
- name: Build app dependencies
run: npm run build:app-deps
- name: Typecheck
run: npm run typecheck --workspace=@getpaseo/app

View File

@@ -21,7 +21,7 @@ jobs:
cache: "npm"
- name: Install dependencies
run: npm install --workspace=@getpaseo/relay --include-workspace-root
run: npm ci --workspace=@getpaseo/relay --include-workspace-root
- name: Typecheck
run: npm run typecheck --workspace=@getpaseo/relay

View File

@@ -5,6 +5,7 @@ on:
branches: [main]
paths:
- "CHANGELOG.md"
- "public-docs/**"
- "packages/website/**"
- "package.json"
- "package-lock.json"
@@ -28,7 +29,7 @@ jobs:
cache: "npm"
- name: Install dependencies
run: npm install --workspace=@getpaseo/website --include-workspace-root
run: npm ci --workspace=@getpaseo/website --include-workspace-root
- name: Typecheck
run: npm run typecheck --workspace=@getpaseo/website

View File

@@ -40,7 +40,7 @@ on:
rollout_hours:
description: "Linear rollout duration in hours. Use 0 for instant rollout."
required: false
default: "24"
default: "36"
type: string
concurrency:
@@ -51,7 +51,7 @@ env:
SOURCE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }}
CHECKOUT_REF: ${{ github.event_name == 'workflow_dispatch' && (github.event.inputs.checkout_ref || github.ref_name) || github.ref_name }}
SHOULD_PUBLISH: ${{ github.event_name != 'workflow_dispatch' || github.event.inputs.publish != 'false' }}
ROLLOUT_HOURS: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.rollout_hours || '24' }}
ROLLOUT_HOURS: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.rollout_hours || '36' }}
DESKTOP_PACKAGE_PATH: "packages/desktop"
jobs:
@@ -183,6 +183,14 @@ jobs:
fi
gh release upload "$RELEASE_TAG" "${files[@]}" --clobber --repo "${{ github.repository }}"
- name: Upload desktop artifacts to workflow
if: env.SHOULD_PUBLISH != 'true'
uses: actions/upload-artifact@v4
with:
name: desktop-macos-${{ matrix.electron_arch }}
path: ${{ env.DESKTOP_PACKAGE_PATH }}/release/*.dmg
retention-days: 7
- name: Upload manifest artifact
if: env.SHOULD_PUBLISH == 'true' && env.IS_SMOKE_TAG != 'true'
uses: actions/upload-artifact@v4
@@ -273,6 +281,14 @@ jobs:
fi
gh release upload "$RELEASE_TAG" "${files[@]}" --clobber --repo "${{ github.repository }}"
- name: Upload desktop artifacts to workflow
if: env.SHOULD_PUBLISH != 'true'
uses: actions/upload-artifact@v4
with:
name: desktop-linux
path: ${{ env.DESKTOP_PACKAGE_PATH }}/release/*
retention-days: 7
- name: Upload manifest artifact
if: env.SHOULD_PUBLISH == 'true' && env.IS_SMOKE_TAG != 'true'
uses: actions/upload-artifact@v4
@@ -360,6 +376,14 @@ jobs:
fi
gh release upload "$RELEASE_TAG" "${files[@]}" --clobber --repo "${{ github.repository }}"
- name: Upload desktop artifacts to workflow
if: env.SHOULD_PUBLISH != 'true'
uses: actions/upload-artifact@v4
with:
name: desktop-windows
path: ${{ env.DESKTOP_PACKAGE_PATH }}/release/*
retention-days: 7
- name: Upload manifest artifact
if: env.SHOULD_PUBLISH == 'true' && env.IS_SMOKE_TAG != 'true'
uses: actions/upload-artifact@v4

View File

@@ -1,45 +0,0 @@
name: Fix Nix hash
on:
push:
branches: [main]
paths:
- "package.json"
- "package-lock.json"
pull_request:
paths:
- "package.json"
- "package-lock.json"
permissions:
contents: write
jobs:
fix-nix-hash:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.head_ref || github.ref }}
token: ${{ secrets.GITHUB_TOKEN }}
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
- uses: cachix/install-nix-action@v31
with:
nix_path: nixpkgs=channel:nixos-unstable
- name: Fix lockfile and update hash
run: ./scripts/update-nix.sh
- name: Commit changes
run: |
git diff --quiet package-lock.json nix/package.nix && exit 0
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add package-lock.json nix/package.nix
git commit -m "fix: update lockfile signatures and Nix hash"
git push

View File

@@ -1,59 +0,0 @@
name: Nix Build
on:
push:
branches: [main]
paths:
- "nix/**"
- "flake.nix"
- "flake.lock"
- "package.json"
- "package-lock.json"
- "packages/highlight/**"
- "packages/server/**"
- "packages/relay/**"
- "packages/cli/**"
pull_request:
branches: [main]
paths:
- "nix/**"
- "flake.nix"
- "flake.lock"
- "package.json"
- "package-lock.json"
- "packages/highlight/**"
- "packages/server/**"
- "packages/relay/**"
- "packages/cli/**"
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
continue-on-error: false
steps:
- uses: actions/checkout@v4
- uses: cachix/install-nix-action@v31
with:
nix_path: nixpkgs=channel:nixos-unstable
- name: Build Nix package
run: nix build .#default -o result
- name: Verify lockfile is complete
# npm silently omits resolved/integrity fields in workspace monorepos.
# Nix needs them for offline builds. See https://github.com/npm/cli/issues/4460
run: |
node scripts/fix-lockfile.mjs package-lock.json
git diff --exit-code package-lock.json || {
echo "ERROR: package-lock.json has missing resolved/integrity fields."
echo "This is a known npm bug: https://github.com/npm/cli/issues/4460"
echo "Run 'node scripts/fix-lockfile.mjs' and commit the result."
exit 1
}
- name: Check npmDepsHash is up to date
run: ./scripts/update-nix.sh --check

60
.github/workflows/nix-update-hash.yml vendored Normal file
View File

@@ -0,0 +1,60 @@
name: Nix Update Hash
on:
push:
branches: [main]
paths:
- "nix/**"
- "flake.nix"
- "flake.lock"
- "package.json"
- "package-lock.json"
- "packages/highlight/**"
- "packages/server/**"
- "packages/relay/**"
- "packages/cli/**"
- "scripts/update-nix.sh"
- "scripts/fix-lockfile.mjs"
- ".github/workflows/nix-update-hash.yml"
permissions:
contents: read
jobs:
update-hash:
runs-on: ubuntu-latest
steps:
- uses: actions/create-github-app-token@v1
id: app-token
with:
app-id: ${{ secrets.PASEO_BOT_APP_ID }}
private-key: ${{ secrets.PASEO_BOT_APP_PRIVATE_KEY }}
- uses: actions/checkout@v4
with:
ref: ${{ github.ref }}
token: ${{ steps.app-token.outputs.token }}
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
- uses: cachix/install-nix-action@v31
with:
nix_path: nixpkgs=channel:nixos-unstable
- name: Update lockfile + Nix hash if stale
run: ./scripts/update-nix.sh
- name: Build Nix package
run: nix build .#default -o result
- name: Commit hash/lockfile updates
run: |
git diff --quiet package-lock.json nix/npm-deps.hash && exit 0
git config user.name "paseo-ai[bot]"
git config user.email "266920839+paseo-ai[bot]@users.noreply.github.com"
git add package-lock.json nix/npm-deps.hash
git commit -m "fix: update lockfile signatures and Nix hash [skip ci]"
git push

101
.github/workflows/nix.yml vendored Normal file
View File

@@ -0,0 +1,101 @@
name: Nix
on:
pull_request:
branches: [main]
paths:
- "nix/**"
- "flake.nix"
- "flake.lock"
- "package.json"
- "package-lock.json"
- "packages/highlight/**"
- "packages/protocol/**"
- "packages/client/**"
- "packages/server/**"
- "packages/relay/**"
- "packages/cli/**"
- "scripts/update-nix.sh"
- "scripts/fix-lockfile.mjs"
- ".github/workflows/nix.yml"
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
- uses: cachix/install-nix-action@v31
with:
nix_path: nixpkgs=channel:nixos-unstable
- name: Update lockfile + Nix hash if stale
run: ./scripts/update-nix.sh
- name: Build Nix package
run: nix build .#default -o result
- name: Smoke Nix daemon
run: |
set -euo pipefail
export PASEO_HOME
PASEO_HOME="$(mktemp -d)"
export PASEO_LISTEN=127.0.0.1:6767
WRAPPER_LOG="$PASEO_HOME/paseo-server-wrapper.log"
cleanup() {
if [[ -n "${DAEMON_PID:-}" ]] && kill -0 "$DAEMON_PID" 2>/dev/null; then
kill "$DAEMON_PID"
wait "$DAEMON_PID" || true
fi
rm -rf "$PASEO_HOME"
}
trap cleanup EXIT
./result/bin/paseo-server --no-relay >"$WRAPPER_LOG" 2>&1 &
DAEMON_PID=$!
deadline=$((SECONDS + 30))
while (( SECONDS < deadline )); do
if STATUS_JSON="$(./result/bin/paseo daemon status --json)" \
&& jq -e '.connectedDaemon == "reachable"' <<<"$STATUS_JSON" >/dev/null; then
echo "$STATUS_JSON"
exit 0
fi
if ! kill -0 "$DAEMON_PID" 2>/dev/null; then
echo "Nix daemon exited before becoming reachable."
break
fi
sleep 1
done
echo "::group::daemon.log"
cat "$PASEO_HOME/daemon.log" 2>/dev/null || echo "<missing>"
echo "::endgroup::"
echo "::group::paseo-server stdout/stderr"
cat "$WRAPPER_LOG" 2>/dev/null || echo "<missing>"
echo "::endgroup::"
echo "::group::paseo daemon status"
./result/bin/paseo daemon status || true
echo "::endgroup::"
exit 1
- name: Build Nix desktop package
run: nix build .#desktop -o result-desktop

View File

@@ -1,52 +0,0 @@
name: Server CI
on:
push:
branches: [main]
paths:
- "packages/server/**"
- "package.json"
- "package-lock.json"
- ".github/workflows/server-ci.yml"
pull_request:
branches: [main]
paths:
- "packages/server/**"
- "package.json"
- "package-lock.json"
- ".github/workflows/server-ci.yml"
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
- name: Fetch origin/main (worktree tests)
run: git fetch --no-tags origin main:refs/remotes/origin/main
- name: Install server dependencies
run: npm install --workspace=@getpaseo/server --include-workspace-root
- name: Build highlight dependency
run: npm run build --workspace=@getpaseo/highlight
- name: Build relay dependency
run: npm run build --workspace=@getpaseo/relay
- name: Typecheck
run: npm run typecheck --workspace=@getpaseo/server
- name: Test
run: npm run test --workspace=@getpaseo/server
env:
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}

6
.gitignore vendored
View File

@@ -6,6 +6,7 @@ build/
dist/
.next/
out/
result
# Environment variables
.env
@@ -13,6 +14,8 @@ out/
.env.local
.env.test.local
.env*.local
.dev.vars
**/.dev.vars
# Logs
*.log
@@ -61,9 +64,11 @@ CLAUDE.local.md
.debug.conversations/
.debug/
.dev/
.paseo/
.wrangler/
**/.wrangler/
**/.tanstack/
# Local agent/tooling artifacts (do not commit)
PLAN.md
@@ -83,3 +88,4 @@ packages/server/src/server/fixtures/dictation/dictation-debug-largest.transcript
/artifacts
packages/desktop/.cache/
packages/desktop/src-tauri/resources/managed-runtime/
app.json

View File

@@ -11,5 +11,5 @@
"arrowParens": "always",
"bracketSameLine": false,
"bracketSpacing": true,
"ignorePatterns": ["*.lock"]
"ignorePatterns": ["*.lock", "**/*.gen.ts", "**/*.gen.tsx"]
}

View File

@@ -1,5 +1,9 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"options": {
"typeAware": false
},
"ignorePatterns": [".dev/**"],
"plugins": ["react", "react-perf", "unicorn", "typescript", "oxc", "import", "promise"],
"categories": {
"correctness": "error",
@@ -53,6 +57,7 @@
"typescript/no-explicit-any": "error",
"typescript/prefer-as-const": "error",
"typescript/no-this-alias": "error",
"typescript/no-unnecessary-type-assertion": "error",
"typescript/consistent-type-definitions": ["error", "interface"],
"import/no-unassigned-import": [

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,7 @@
Paseo is a mobile app for monitoring and controlling your local AI coding agents from anywhere. Your dev environment, in your pocket. Connects directly to your actual development environment — your code stays on your machine.
**Supported agents:** Claude Code, Codex, and OpenCode.
**Supported agents:** Claude Code, Codex, GitHub Copilot, OpenCode, and Pi.
## Repository map
@@ -15,24 +15,45 @@ This is an npm workspace monorepo:
- `packages/desktop` — Electron desktop wrapper
- `packages/website` — Marketing site (paseo.sh)
## Documentation
## Docs
| Doc | What's in it |
| ---------------------------------------------------- | --------------------------------------------------------------------------------- |
| [docs/architecture.md](docs/architecture.md) | System design, package layering, WebSocket protocol, agent lifecycle, data flow |
| [docs/coding-standards.md](docs/coding-standards.md) | Type hygiene, error handling, state design, React patterns, file organization |
| [docs/testing.md](docs/testing.md) | TDD workflow, determinism, real dependencies over mocks, test organization |
| [docs/development.md](docs/development.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP |
| [docs/release.md](docs/release.md) | Release playbook, draft releases, completion checklist |
| [docs/custom-providers.md](docs/custom-providers.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries |
| [docs/android.md](docs/android.md) | App variants, local/cloud builds, EAS workflows |
| [docs/design.md](docs/design.md) | How to design features before implementation |
| [SECURITY.md](SECURITY.md) | Relay threat model, E2E encryption, DNS rebinding, agent auth |
`docs/` is the source of truth for system-level and process-level knowledge. **"The docs", "check the docs", or "check the X docs" always mean this directory — not the web.** Look here before fetching anything online; the docs capture gotchas and conventions you cannot derive from the code or external sources.
At the start of non-trivial work, list `docs/` and skim anything relevant to the task. When you learn something meta worth preserving — a gotcha, a convention, a workflow, a piece of system context that will outlive the current task — update an existing doc or propose a new one. Code-level facts belong in inline comments next to the code; system, process, and gotcha-level facts belong in `docs/`.
| 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/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/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/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 |
| [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
@@ -41,6 +62,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
@@ -55,19 +78,28 @@ See [docs/development.md](docs/development.md) for full setup, build sync requir
- Never re-run a test suite that another agent already ran and reported green — trust the result.
- For full suite verification, push to CI and check GitHub Actions instead.
- **Always run typecheck and lint after every change.**
- **Build workspace packages before diagnosing cross-package type errors.** This repo consumes generated declarations across workspaces. If typecheck fails in a package that depends on another workspace (especially CLI depending on server/daemon types), rebuild the owning package first so `dist` declarations are current:
- `npm run build:daemon` — rebuild highlight, relay, server, and CLI when daemon/server/CLI types may be stale.
- **Build workspace packages before diagnosing cross-package type errors.** This repo consumes generated declarations across workspaces. If typecheck fails in a package that depends on another workspace, rebuild the owning stack first so `dist` declarations are current:
- `npm run build:client` — rebuild protocol and client declarations.
- `npm run build:server` — rebuild highlight, relay, protocol, client, server, and CLI when server/CLI types may be stale.
- Do not patch inferred callback parameters or add local duplicate types just to silence stale declaration errors.
- **Run `npm run format` before committing.** This repo uses Biome for formatting. Do not manually fix formatting — let the formatter handle it.
- **Always use npm scripts for linting and formatting.** Do not run tools directly with `npx eslint`, `npx oxfmt`, `npx oxlint`, or package-local binaries. For targeted checks, pass file paths through the npm script:
- `npm run lint -- packages/app/src/components/message.tsx`
- `npm run format:files -- CLAUDE.md packages/app/src/components/message.tsx`
- **NEVER make breaking changes to WebSocket or message schemas.** The primary compatibility path is old mobile app clients talking to newly updated daemons. Users update desktop and daemon first, then keep running the old app for a while. Every schema change MUST be backward-compatible for old clients against new daemons:
- New fields: always `.optional()` with a sensible default or `.transform()` fallback.
- Never change a field from optional to required.
- Never remove a field — deprecate it (keep accepting it, stop sending it).
- Never narrow a field's type (e.g. `string``enum`, `nullable` → non-null).
- Test with: "does a 6-month-old client still parse this?" and "does a 6-month-old daemon still send something this client accepts?"
- **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.
- 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?"
- **Feature contract (per-feature):** a new feature may require a new daemon capability. The client detects whether the capability is present and either runs the feature or shows "Update the host to use this." That's it.
- **No fallback paths.** Don't write a degraded version of a new feature that runs on old daemons. Don't fan out across legacy RPCs to simulate a missing capability. The user upgrades or doesn't get the feature.
- **No defensive branches scattered through the feature.** Capability detection happens in one place; downstream code reads a clean shape.
- **Capability flags live in `server_info.features.*`** with a single `// COMPAT(featureName): added in v0.1.X, drop the gate when floor >= v0.1.X` comment marking the cleanup site.
- Existing functionality keeps working across versions — that's the protocol contract doing its job. New-feature degradation is not the goal.
- **New RPCs use dotted namespaces with direction suffixes.** Follow [docs/rpc-namespacing.md](docs/rpc-namespacing.md): `domain.provider.operation.request` pairs with `domain.provider.operation.response`. Existing flat RPC names will migrate over time; don't add new ones.
- **All back-compat shims are tagged and dated for cleanup.** Every shim that exists for old-client/old-daemon support carries a `COMPAT(name)` comment with the version it was added in and a target removal date (typically 6 months out). One grep — `rg "COMPAT\("` — should produce the full list of cleanup work. Don't bury back-compat in untagged `??`-fallbacks or optional-chain tunnels — that's how it stops being deletable.
## Platform gating

View File

@@ -1,168 +1,32 @@
# Contributing to Paseo
Thanks for taking the time to contribute.
Paseo is an opinionated product maintained by one person.
## How this project works
I read every issue and PR myself, and I am selective about what contributions I accept.
Paseo is a BDFL project. Product direction, scope, and what ships are the maintainer's call.
Good ideas still need to fit the shape of the product: a PR can be technically correct and still not belong in Paseo.
This means:
Core product, design, architecture, and workflow changes are not accepted.
- PRs submitted without prior discussion will likely be rejected, heavily modified, or scoped down.
- The maintainer may rewrite, split, cherry-pick from, or close any PR at their discretion.
- There is no obligation to merge a PR as-submitted, regardless of code quality.
Follow these rules if you want your PR to be merged:
This is not meant to discourage contributions. It is meant to set clear expectations so nobody wastes their time.
- Keep it to one focused change
- Link to an issue
- Explain the problem you're solving
- Include repro steps if it's a bug
- Include QA/testing evidence
- UI changes need screenshots or video for every affected platform: iOS, Android, desktop, and web
- If you only tested one platform, say that clearly
## How to contribute
Your PR will be closed if you do any of these:
1. **Open an issue first.** Describe the problem or improvement. Get a thumbs up before writing code.
2. **Keep it small.** One bug, one flow, one focused change.
3. **Open a PR** once there is alignment on scope.
- Bundle unrelated changes
- Fail basic checks like typecheck, formatting or linting
- Make product, design, or architecture changes without prior discussion
- Submit no evidence of testing
- Skip the linked issue
- Clearly fully AI-generated PR
If you want to propose a direction change, start a conversation.
## AI assistance
## Before you start
Please read these first:
- [README.md](README.md)
- [docs/architecture.md](docs/architecture.md)
- [docs/development.md](docs/development.md)
- [docs/coding-standards.md](docs/coding-standards.md)
- [docs/testing.md](docs/testing.md)
- [CLAUDE.md](CLAUDE.md)
## What is most helpful
The most useful contributions right now are:
- bug fixes
- windows and linux specific fixes
- regression fixes
- doc improvements
- packaging / platform fixes
- focused UX improvements that fit the existing product direction
- tests that lock down important behavior
## Scope expectations
Please keep PRs narrow.
Good:
- fix one bug
- improve one flow
- add one focused panel or command
- tighten one piece of UI
Bad:
- combine multiple product ideas in one PR
- bundle unrelated refactors with a feature
- sneak in roadmap decisions
If a contribution contains multiple ideas, split it up.
## Product fit matters
Paseo is an opinionated product.
When reviewing contributions, the bar is not just:
- is this useful?
- is this well implemented?
It is also:
- does this fit Paseo?
- does this add product surface that will be hard to maintain?
- does the value justify the maintenance surface it adds?
- does this solve a common need or over-serve an edge case?
- does this preserve the product's current direction?
## Development setup
### Prerequisites
- Node.js matching `.tool-versions`
- npm workspaces
### Start local development
```bash
# runs both daemon and expo app
npm run dev
```
Useful commands:
```bash
npm run dev:server
npm run dev:app
npm run dev:desktop
npm run dev:website
npm run cli -- ls -a -g
```
Read [docs/development.md](docs/development.md) for build-sync gotchas, local state, ports, and daemon details.
## Multi-platform testing
Paseo ships to mobile (iOS/Android), web, and desktop (Electron). Every UI change must be tested on mobile and web at minimum, and desktop if relevant. Things that look fine on one surface regularly break on another.
Common checks:
```bash
npm run typecheck
npm run test --workspaces --if-present
```
Important rules:
- always run `npm run typecheck` after changes
- tests should be deterministic
- prefer real dependencies over mocks when possible
- do not make breaking WebSocket / protocol changes
- app and daemon versions in the wild lag each other, so compatibility matters
If you touch protocol or shared client/server behavior, read the compatibility notes in [CLAUDE.md](CLAUDE.md).
## Coding standards
Paseo has explicit standards. Follow them.
The full guide lives in [docs/coding-standards.md](docs/coding-standards.md).
## PR checklist
Before opening a PR, make sure:
- there was prior discussion and alignment on scope (issue or conversation)
- the change is focused, one idea per PR
- the PR description explains what changed and why
- **UI changes include screenshots or videos** for every affected platform (mobile, web, desktop)
- UI changes have been tested on mobile and web at minimum
- typecheck passes
- tests pass, or you clearly explain what could not be run
- relevant docs were updated if needed
## Communication
If you are unsure whether something fits, ask first.
That is especially true for:
- new core UX
- naming / terminology changes
- new extension points
- new orchestration models
- anything that would be hard to remove later
Early alignment saves everyone time.
## Forks are fine
If you want to explore a different product direction, a fork is completely fine.
Paseo is open source on purpose. Not every idea needs to land in the main repo to be valuable.
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.

172
README.ja.md Normal file
View File

@@ -0,0 +1,172 @@
<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 実装のセルフホスト型リレー
---
<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">
@@ -17,9 +23,12 @@
<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">One interface for all your Claude Code, Codex and OpenCode agents.</p>
<p align="center">One interface for Claude Code, Codex, Copilot, OpenCode, and Pi agents.</p>
<p align="center">
<img src="https://paseo.sh/hero-mockup.png" alt="Paseo app screenshot" width="100%">
@@ -29,12 +38,16 @@
<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.
- **Self-hosted:** Agents run on your machine with your full dev environment. Use your tools, your configs, and your skills.
- **Multi-provider:** Claude Code, Codex, and OpenCode through the same interface. Pick the right model for each job.
- **Multi-provider:** Claude Code, Codex, Copilot, OpenCode, and Pi through the same interface. Pick the right model for each job.
- **Voice control:** Dictate tasks or talk through problems in voice mode. Hands-free when you need it.
- **Cross-device:** iOS, Android, desktop, web, and CLI. Start work at your desk, check in from your phone, script it from the terminal.
- **Privacy-first:** Paseo doesn't have any telemetry, tracking, or forced log-ins.
@@ -49,7 +62,9 @@ You need at least one agent CLI installed and configured with your credentials:
- [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)
### Desktop app (recommended)
@@ -91,9 +106,9 @@ paseo --host workstation.local:6767 run "run the full test suite"
See the [full CLI reference](https://paseo.sh/docs/cli) for more.
## Orchestration skills (Unstable)
## Skills
Experimental skills that teach agents how to use the Paseo CLI to orchestrate other agents. I am updating these very frequently as I learn new things, expect changes without notice, might be coupled to my own setup, use at your own risk.
Skills teach your agent to use Paseo to orchestrate other agents.
```bash
npx skills add getpaseo/paseo
@@ -101,18 +116,10 @@ npx skills add getpaseo/paseo
Then use them in any agent conversation:
```bash
# Use handoff when you discuss something with an agent but want another one to implement.
# I use this to plan with Claude and then handoff to Codex to implement.
/paseo-handoff hand off the authentication fix to codex 5.4 in a worktree
# Use loops when you have clear acceptance criteria (aka Ralph loops).
/paseo-loop loop a codex agent to fix the backend tests, use sonnet to verify, max 10 iterations
# Orchestrator teaches the agent how to create teams and manage them via a chat room.
# Very opinionated and expects both Codex and Claude to work.
/paseo-orchestrator spin up a team to implement the database refactor, use chat to coordinate. use claude to plan and codex to implement and review
```
- `/paseo-handoff` — hand off work between agents. I use this to plan with Claude and then handoff to Codex to implement.
- `/paseo-loop` — loop an agent against clear acceptance criteria (aka Ralph loops), optionally with a verifier.
- `/paseo-advisor` — spin up a single agent as an advisor for a second opinion, without delegating the work itself.
- `/paseo-committee` — form a committee of two contrasting agents to step back, do root cause analysis, and produce a plan.
## Development
@@ -137,13 +144,19 @@ npm run dev:app
npm run dev:desktop
npm run dev:website
# build the daemon
npm run build:daemon
# build the server stack
npm run build:server
# repo-wide checks
npm run typecheck
```
## Community
- [paseo-relay](https://github.com/zenghongtu/paseo-relay) — self-hosted relay in Go
---
<p align="center">
<a href="https://star-history.com/#getpaseo/paseo&Date">
<picture>

218
README.zh-CN.md Normal file
View File

@@ -0,0 +1,218 @@
<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
### 自托管 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

@@ -19,18 +19,19 @@ The relay is designed to be untrusted. All traffic between your phone and daemon
### How it works
1. The daemon generates a persistent ECDH keypair and stores it locally
2. When you scan the QR code or click the pairing link, your phone receives the daemon's public key
3. Your phone sends a handshake message with its own public key. The daemon will not accept any commands until this handshake completes.
4. Both sides perform an ECDH key exchange to derive a shared secret. All subsequent messages are encrypted with XSalsa20-Poly1305 (NaCl box).
1. The daemon generates a persistent Curve25519 keypair on first run and stores it at `$PASEO_HOME/daemon-keypair.json` with mode `0600`
2. The pairing URL (rendered as a QR code or opened directly) carries the daemon's public key in its URL fragment (`https://app.paseo.sh/#offer=...`). Fragments are not sent to the web server, so `app.paseo.sh` never sees the key.
3. When the phone connects via the relay, it generates a fresh ephemeral Curve25519 keypair and sends an `e2ee_hello` message containing its public key. The daemon will not process any application messages until this handshake completes.
4. Both sides perform a Curve25519 ECDH key exchange to derive a shared key. All subsequent messages are encrypted with XSalsa20-Poly1305 (NaCl `box`). The wire format is `[24-byte nonce][ciphertext]`, base64-encoded as a WebSocket text frame.
The relay sees only: IP addresses, timing, message sizes, and session IDs. It cannot read message contents, forge messages, or derive encryption keys from observing the handshake.
The relay sees only: IP addresses, timing, message sizes, session IDs, and the plaintext `e2ee_hello` / `e2ee_ready` handshake frames (which contain only public keys). It cannot read message contents, forge messages, or derive encryption keys from observing the handshake.
### Why the relay can't attack you
The daemon requires a valid cryptographic handshake before processing any commands. A compromised relay cannot:
- **Send commands** — Without your phone's private key, it cannot complete the handshake
- **Impersonate the daemon to your phone** — Without the daemon's secret key, it cannot derive the shared key, so any traffic it injects fails authenticated decryption on the phone
- **Send commands as you** — The daemon only accepts traffic that decrypts and authenticates under a shared key derived with its own secret key. The phone's keypair is ephemeral per connection, so there is no persistent phone-side secret to steal; protection comes from the daemon's secret key never leaving the daemon.
- **Read your traffic** — All messages are encrypted with XSalsa20-Poly1305 (NaCl box) after the handshake
- **Forge messages** — NaCl box provides authenticated encryption; tampered messages are rejected
- **Replay old messages across sessions** — Each session derives fresh encryption keys, so ciphertext from one session cannot be replayed into another session. Within a live session, replay protection is not yet implemented; the protocol uses random nonces and does not track nonce reuse or message counters.
@@ -41,11 +42,13 @@ The QR code or pairing link is the trust anchor. It contains the daemon's public
## Local daemon trust boundary
By default, the daemon binds to `127.0.0.1`. The local control plane is trusted by network reachability, not by an additional authentication token.
By default, the daemon binds to `127.0.0.1`. With no password configured, the local control plane is trusted by network reachability — anything that can reach the daemon socket can control the daemon. This is the same security model Docker documents for its daemon: the security boundary is access to the socket or listening address.
Anything that can reach the daemon socket can control the daemon. This is the same security model Docker documents for its daemon: the security boundary is access to the socket or listening address.
The daemon also supports an optional shared-secret password (set via `auth.password` in `config.json` or the `PASEO_PASSWORD` env var; stored bcrypt-hashed). When configured, every HTTP request must carry `Authorization: Bearer <password>` and every WebSocket upgrade must include a `Sec-WebSocket-Protocol: paseo.bearer.<password>` subprotocol. Browser WebSocket cannot set custom headers, which is why the token rides in the subprotocol. Health (`GET /api/health`) and CORS preflight (`OPTIONS`) are exempt. The password is intended for direct-TCP exposure (e.g. `tcp://host:port?ssl=true&password=...`); it is **not** a substitute for the relay's E2E encryption when traversing untrusted networks.
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.
Connected clients are trusted operators of the daemon user. File previews follow that authority: a preview request may read any regular file the daemon process can read, while keeping path normalization and symlink checks in the daemon file service. Workspace-relative paths remain a UI convenience, not a security boundary.
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.
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.
@@ -55,7 +58,7 @@ Host header validation and CORS origin checks are defense-in-depth controls for
CORS is not a complete security boundary. It controls which browser origins can make requests, but does not prevent a malicious website from resolving its domain to your local machine (DNS rebinding).
Paseo validates the `Host` header on incoming requests against configured hostnames. Requests with unrecognized hosts are rejected.
Paseo validates the `Host` header on every HTTP request and every WebSocket upgrade against an allowlist (Vite-style semantics). By default, only `localhost`, `*.localhost`, and any literal IP address (IPv4 or IPv6) are accepted. Additional hostnames can be configured via `hostnames` in `config.json` or the `PASEO_HOSTNAMES` env var (comma-separated; entries beginning with `.` match a domain and its subdomains; the value `true` disables the allowlist entirely). Requests with unrecognized hosts are rejected with `403 Host not allowed`.
## Agent authentication

View File

@@ -1,6 +1,10 @@
# Ad-hoc daemon testing
Spin up an isolated daemon programmatically without touching the main daemon on port 6767.
Spin up an isolated in-process daemon test harness without touching the main daemon on port 6767.
This is for test code only. Executable daemon processes must start through
`scripts/supervisor-entrypoint.ts` or `dist/scripts/supervisor-entrypoint.js`;
do not use `createPaseoDaemon` as a product launch path.
## Quick start
@@ -44,7 +48,7 @@ const port = target!.type === "tcp" ? target!.port : null;
const client = new DaemonClient({
url: `ws://127.0.0.1:${port}/ws`,
appVersion: "0.1.54", // see gotcha #1
appVersion: "0.1.70", // see gotcha #1
});
await client.connect();
await client.fetchAgents({ subscribe: { subscriptionId: "test" } });
@@ -74,7 +78,7 @@ import { DaemonClient } from "./test-utils/daemon-client.js";
const daemon = await createTestPaseoDaemon();
const client = new DaemonClient({
url: `ws://127.0.0.1:${daemon.port}/ws`,
appVersion: "0.1.54",
appVersion: "0.1.70",
});
await client.connect();
await client.fetchAgents({ subscribe: { subscriptionId: "test" } });
@@ -85,7 +89,7 @@ await client.close();
await daemon.close(); // stops daemon + cleans up temp dirs
```
The test helper does **not** expose `providerOverrides`. Use `createPaseoDaemon` directly when you need it (see quick start above).
The test helper does **not** expose `providerOverrides`. In test harnesses, use `createPaseoDaemon` directly when you need it (see quick start above).
## Common client methods
@@ -112,7 +116,7 @@ Always pass `appVersion`:
```typescript
const client = new DaemonClient({
url: `ws://127.0.0.1:${port}/ws`,
appVersion: "0.1.54",
appVersion: "0.1.70",
});
```

123
docs/agent-lifecycle.md Normal file
View File

@@ -0,0 +1,123 @@
# Agent lifecycle
How an agent is created, runs, becomes a subagent, gets archived, and disappears from the UI. The model spans the daemon (lifecycle, archive) and the client (tabs, the subagents track).
## States
```
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.
## Relationships
Agents can launch other agents via the agent-scoped `create_agent` MCP tool. Agent-scoped creation is always asynchronous. `relationship` and `workspace` are separate decisions:
- `relationship` decides whether the new agent belongs under the caller.
- `workspace` decides where the new agent lives and whether a new workspace/worktree is created.
`relationship: { kind: "subagent" }` stamps the created agent with `paseo.parent-agent-id`, pointing back at the creating agent. The client surfaces that as `agent.parentAgentId`. This requires an agent-scoped MCP session.
`relationship: { kind: "detached" }` creates a sibling/root agent (e.g. handoffs, fire-and-forget delegations). The daemon may still use the creating agent for cwd/config inheritance, but it does not write `paseo.parent-agent-id`.
- **Subagents** — exist as part of the creating agent's work, appear in that agent's subagent track, and are archived with it.
- **Detached agents** — stand on their own, do not appear in the creating agent's subagent track, and are not archived with it.
`workspace: { kind: "current" }` uses the caller's workspace and can optionally override the runtime cwd. It requires an agent-scoped MCP session. `workspace: { kind: "create", source: { kind: "directory" | "worktree", ... } }` creates a new workspace for the new agent; worktree creation goes through the Paseo worktree workflow and stamps the agent with that fresh workspace id.
Users can also detach an existing subagent from the subagents track. Detach 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.
## 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.
Archiving runs through `AgentManager.archiveAgent` (`packages/server/src/server/agent/agent-manager.ts`):
1. Snapshot the current session into the registry
2. Set `archivedAt` and normalize `lastStatus` away from `running`/`initializing`
3. Notify subscribers
4. Close the runtime (kills the process if still running)
5. **Cascade-archive children** — any agent whose `paseo.parent-agent-id` label matches the archived agent gets archived too, recursively
Cascade is what keeps subagent fleets from outliving their orchestrator.
## Tabs vs archive
These are two distinct concepts that used to be conflated:
| Concept | Scope | Triggers |
| -------------------------- | ---------- | -------------------------- |
| **Tab** (workspace layout) | Per-client | User opens/closes a view |
| **Archive** (lifecycle) | Global | Explicit lifecycle gesture |
Closing a tab on a **root agent** still archives — the tab is the agent's home, so closing it means "I'm done with this agent." A confirm dialog protects against archiving a running agent by accident.
Closing a tab on a **subagent** (any agent with `parentAgentId`) is **layout-only**. The agent stays unarchived and stays in its parent's track. The user can re-open the tab from the track at any time. This is implemented in `handleCloseAgentTab` (`packages/app/src/screens/workspace/workspace-screen.tsx`).
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.
## 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`**: a workspace's status reflects only records whose `workspaceId === workspace.id`. Ownership is never derived from `cwd` — many workspaces may share one directory, and same-`cwd` siblings do not clump under one status. A root agent contributes its normal state bucket to its owning workspace only. Running subagents contribute `running` to their root parent's owning workspace (by the parent agent's `workspaceId`), not to the subagent's current `cwd` or worktree. Non-running subagent attention, permission, and error states stay in the parent's subagents track and do not escalate the workspace bucket.
## 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`):
```
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.
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
The decision was to **decouple "close tab" from "archive" only for subagents**, rather than universally:
- **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
### 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.
### Cross-client tab dismissal
Closing a subagent's tab on one client doesn't affect other clients' layouts. This is the expected behavior of decoupled tabs and is consistent with how layouts have always worked. Archive remains the global gesture for cross-client cleanup.
## Storage
```
$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` when `relationship.kind === "subagent"` |
| `lastStatus` | `AgentStatus` | `initializing` / `idle` / `running` / `error` / `closed` |
See [`docs/data-model.md`](./data-model.md) for the full agent record.

View File

@@ -27,17 +27,21 @@ Or from `packages/app`:
```bash
# Debug
APP_VARIANT=development npx expo prebuild --platform android --non-interactive
APP_VARIANT=development npx expo run:android --variant=debug
npx cross-env APP_VARIANT=development expo prebuild --platform android --non-interactive
npx cross-env APP_VARIANT=development expo run:android --variant=debug
# Release
APP_VARIANT=production npx expo prebuild --platform android --non-interactive
APP_VARIANT=production npx expo run:android --variant=release
npx cross-env APP_VARIANT=production expo prebuild --platform android --non-interactive
npx cross-env APP_VARIANT=production expo run:android --variant=release
# Clear generated Android project
rm -rf android
```
### 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.
## Screenshots
```bash
@@ -48,22 +52,28 @@ adb exec-out screencap -p > screenshot.png
Stable tag pushes like `v0.1.0` trigger:
- `packages/app/.eas/workflows/release-mobile.yml` on Expo servers (iOS + Android build + submit)
- `.github/workflows/android-apk-release.yml` on GitHub Actions (APK asset on GitHub Release)
- The EAS GitHub app on Expo servers (iOS + Android production builds + store submit). There is no workflow file in this repo for it.
- `.github/workflows/android-apk-release.yml` on GitHub Actions (APK asset on GitHub Release).
iOS auto-submits to App Store review via a Fastlane lane after EAS uploads to TestFlight. Android auto-submits to the Play Store via EAS-managed credentials.
Beta tags like `v0.1.1-beta.1` only trigger the GitHub APK workflow. They publish a GitHub prerelease APK for testing and do not submit to the stores.
`android-v*` tags also trigger only the GitHub APK workflow — useful when you want to ship an APK without going through stores. The GitHub APK workflow supports `workflow_dispatch` with an existing `tag` input so you can rebuild without cutting a new tag.
### Useful commands
```bash
cd packages/app
# List recent workflow runs
npx eas workflow:runs --workflow release-mobile.yml --limit 10
# Recent builds
npx eas build:list --limit 10 --non-interactive --json | jq '.[] | {platform, status, appVersion, gitCommitHash}'
# Inspect a run
npx eas workflow:view <run-id>
# Stream logs for a failed job
npx eas workflow:logs <job-id> --non-interactive --all-steps
# Inspect a build (the printed `Logs` URL opens the build's Expo dashboard page,
# which has a Submissions section showing the auto-submit to the Play Store).
npx eas build:view <build-id>
```
The Play Console (Internal testing → Production tracks) is the final confirmation that the binary reached the store.
See [docs/release.md](release.md) for the full mobile-build babysitting flow.

View File

@@ -22,13 +22,13 @@ Your code never leaves your machine. Paseo is local-first.
│ (Node.js) │
└──────┬──────┘
┌────────────┼────────────┐
│ │ │
┌─────▼─────┐ ┌───▼────┐ ┌────▼─────┐
│ Claude │ │ Codex │ │ OpenCode
│ Agent │ │ Agent │ │ Agent │
│ SDK │ │ Server │ │ │
└───────────┘ └────────┘ └──────────┘
┌────────────┼────────────┬────────────┬────────────
│ │ │ │ │
┌─────▼─────┐ ┌───▼────┐ ┌──────▼─────┐ ┌────▼─────┐ ┌────▼────┐
│ Claude │ │ Codex │ │ Copilot │ │ OpenCode │ │ Pi
│ Agent │ │ Agent │ │ Agent │ │ Agent │ │ Agent │
│ SDK │ │ Server │ │ ACP │ │ │ │
└───────────┘ └────────┘ └────────────┘ └──────────┘ └─────────
```
## Components at a glance
@@ -48,42 +48,69 @@ 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/`.
**Key modules:**
| Module | Responsibility |
| ------------------------- | ----------------------------------------------------------------------------- |
| `bootstrap.ts` | Daemon initialization: HTTP server, WS server, agent manager, storage, relay |
| `websocket-server.ts` | WebSocket connection management, hello/welcome handshake, binary multiplexing |
| `session.ts` | Per-client session state, timeline subscriptions, terminal operations |
| `agent/agent-manager.ts` | Agent lifecycle state machine, timeline tracking, subscriber management |
| `agent/agent-storage.ts` | File-backed JSON persistence at `$PASEO_HOME/agents/` |
| `agent/mcp-server.ts` | MCP server for sub-agent creation, permissions, timeouts |
| `providers/` | Provider adapters: Claude (Agent SDK), Codex (AppServer), OpenCode |
| `relay-transport.ts` | Outbound relay connection with E2E encryption |
| `client/daemon-client.ts` | Client library for connecting to the daemon (used by CLI and app) |
| 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 Paseo tool catalog for subagents, permissions, worktrees |
| `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
The source of truth for WebSocket messages, binary frame codecs, endpoint parsing,
agent timeline types, provider config schemas, and other values shared by daemon
and clients. Server, app, CLI, and `@getpaseo/client` all depend on this package;
it does not depend on the server.
### `packages/client` — Daemon client library and SDK facade
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`.
### `packages/app` — Mobile + web client (Expo)
Cross-platform React Native app that connects to one or more daemons.
- Expo Router navigation (`/h/[serverId]/agents`, etc.)
- `DaemonRegistryContext` manages saved daemon connections
- Expo Router navigation (`/h/[serverId]/workspace/[workspaceId]`, `/h/[serverId]/agent/[agentId]`, etc.). The `workspaceId` URL segment is an opaque workspace id (path-shaped today and opaque-encoded for routing), not a directly meaningful filesystem path.
- `HostRuntimeController` manages saved host connections, reconnection, and per-host runtime state
- `SessionContext` wraps the daemon client for the active session
- `Stream` model handles timeline with compaction, gap detection, sequence-based deduplication
- 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)
### `packages/cli` — Command-line client
Commander.js CLI with Docker-style commands:
Commander.js CLI with Docker-style commands. Common agent operations are also exposed at the top level (e.g. `paseo ls`, `paseo run`).
- `paseo agent ls/run/stop/logs/inspect/wait/send/attach`
- `paseo daemon start/stop/restart/status/pair`
- `paseo agent ls/run/import/attach/logs/stop/delete/send/inspect/wait/archive/reload/update/mode`
- `paseo daemon start/stop/restart/status/pair/set-password`
- `paseo chat ls/create/inspect/post/read/wait/delete`
- `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 permit allow/deny/ls`
- `paseo provider ls/models`
- `paseo worktree ls/archive`
- `paseo worktree create/ls/archive`
- `paseo speech …`
Communicates with the daemon via the same WebSocket protocol as the app.
@@ -91,10 +118,11 @@ Communicates with the daemon via the same WebSocket protocol as the app.
Enables remote access when the daemon is behind a firewall.
- ECDH key exchange + AES-256-GCM encryption
- Curve25519 ECDH key exchange + XSalsa20-Poly1305 (NaCl `box`) encryption
- Relay server is zero-knowledge — it routes encrypted bytes, cannot read content
- Client and daemon channels with identical API (`createClientChannel`, `createDaemonChannel`)
- Pairing via QR code transfers the daemon's public key to the client
- Self-hosted relays opt into TLS with `daemon.relay.useTls` or `PASEO_RELAY_USE_TLS=true`; the public (client-facing) TLS setting can be overridden independently via `daemon.relay.publicUseTls` or `PASEO_RELAY_PUBLIC_USE_TLS`
See [SECURITY.md](../SECURITY.md) for the full threat model.
@@ -106,36 +134,66 @@ 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 panes are not yet per-window.** The active-browser id (`features/browser-webviews.ts`) and the webview registration queue (`pendingBrowserWebviewIds` in `main.ts`) are process-global. With browser panes open in two windows, a menu Reload can target the other window's webview, and near-simultaneous webview attach across windows can register under the wrong browser id. Multi-window v1 ships windows; making the browser-webview subsystem window-scoped is a follow-up.
### `packages/website` — Marketing site
TanStack Router + Cloudflare Workers. Serves paseo.sh.
## WebSocket protocol
All clients speak the same binary-multiplexed WebSocket protocol.
All clients speak the same WebSocket protocol over a single connection that mixes JSON text frames and a small binary framing for terminal streams. Schemas live in `packages/protocol/src/messages.ts`.
**Handshake:**
```
Client → Server: WSHelloMessage { id, clientId, version, timestamp }
Server → Client: WSWelcomeMessage { clientId, daemonVersion, sessionId, capabilities }
Client → Server: WSHelloMessage {
type: "hello",
clientId,
clientType: "mobile" | "browser" | "cli" | "mcp",
protocolVersion,
appVersion?,
capabilities?: { voice?, pushNotifications?, ... },
}
Server → Client: status message with payload { status: "server_info",
serverId, hostname, version, capabilities?, features }
```
**Message types:**
There is no dedicated welcome message; the server emits a `status` session message after accepting the hello, then begins streaming. The session stores client capabilities from the hello and rehydrates them on reconnect, so the wire boundary can ask one question: `session.supports(...)`.
**Top-level WS envelopes** are `hello`, `recording_state`, `ping`/`pong`, and `session` (which wraps the rich union of session messages).
Client liveness checks use the top-level JSON `ping`/`pong` envelope, not a session RPC and not RFC6455 protocol ping. The app runs through browser and React Native WebSocket APIs, which do not expose protocol ping, so this envelope is the portable way to test the direct or relay data path. Session RPC timeouts are operation failures and must not be treated as proof that the socket is dead.
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.
**Notable session message types:**
- `agent_update` — Agent state changed (status, title, labels)
- `agent_stream` — New timeline event from a running agent
- `workspace_update` — Workspace state changed
- `agent_permission_request` — Agent needs user approval for a tool call
- Command-response pairs for fetch, list, create, etc.
- `workspace_update`, `script_status_update`, `workspace_setup_progress` — Workspace state
- `agent_permission_request` / `agent_permission_resolved` — Tool-call permission flow
- `agent_deleted`, `agent_archived`, `agent_status`, `agent_list`
- `checkout_status_update`, `checkout_diff_update`, and the full `checkout_*` request/response set for git operations
- Terminal subscribe/input/capture commands
- 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`
**Binary multiplexing:**
**Binary frames (terminal stream protocol):**
Terminal I/O and agent streaming share the same connection via `BinaryMuxFrame`:
Terminal I/O is sent as binary WebSocket frames decoded by `decodeTerminalStreamFrame` in `shared/binary-frames/terminal.ts`. The layout is:
- Channel 0: control messages
- Channel 1: terminal data
- 1-byte channel ID + 1-byte flags + variable payload
- 1-byte opcode: `Output (0x01)`, `Input (0x02)`, `Resize (0x03)`, `Snapshot (0x04)`
- 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
@@ -154,26 +212,77 @@ Example: adding a new enum value
## Agent lifecycle
The lifecycle states are defined in `shared/agent-lifecycle.ts`:
```
initializing → idle running → idle (or error → closed)
└────────┘ (agent completes a turn, awaits next prompt)
initializing → idle running
error
closed
```
- **AgentManager** tracks up to 200 timeline items per agent
- Timeline is append-only with epochs (each run starts a new epoch)
- Events stream to all subscribed clients in real time
- Agent state persists to `$PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json`
- `initializing` — provider session is being created
- `idle` — has a live session, awaiting the next prompt
- `running` — provider is currently producing a turn
- `error` — last attempt failed; session is still attached
- `closed` — terminal state, no live session
`ManagedAgent` is a discriminated union over those lifecycle tags. Notes:
- **AgentManager** is the source of truth for agent state and broadcasts updates to all subscribers
- Timeline is append-only with epochs (each run starts a new epoch). Storage uses sequence numbers for client-side dedup; the default fetch page is 200 items
- Timeline row `timestamp` values are canonical daemon-owned timestamps. Providers may supply original replay timestamps, but clients must not guess timestamp trust or hide time UI based on local clock heuristics.
- Events stream to connected clients in real time; correctness is backed by authoritative timeline fetches and paged-to-completion catch-up.
- Agent state persists to `$PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json` (timeline rows live alongside the record). That storage path is derived from `cwd`, not from workspace id.
## 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` |
| GitHub PR status | `checkoutPrStatusQueryKey(serverId, cwd)` | `packages/app/src/git/query-keys.ts` |
| PR pane 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 a common `AgentClient` interface:
Each provider implements the `AgentClient` interface in `agent/agent-sdk-types.ts`. Provider implementations live in `agent/providers/`.
| Provider | Wraps | Session format |
| -------- | ------------------- | -------------------------------------------------- |
| Claude | Anthropic Agent SDK | `~/.claude/projects/{cwd}/{session-id}.jsonl` |
| Codex | CodexAppServer | `~/.codex/sessions/{date}/rollout-{ts}-{id}.jsonl` |
| OpenCode | OpenCode CLI | Provider-managed |
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 |
| ------------------ | ------------------------------------ | -------------------------------------------------- |
| Claude (`claude/`) | Anthropic Agent SDK | `~/.claude/projects/{cwd}/{session-id}.jsonl` |
| Codex | Codex AppServer (`codex-app-server`) | `~/.codex/sessions/{date}/rollout-{ts}-{id}.jsonl` |
| Copilot | GitHub Copilot via ACP | Provider-managed |
| OpenCode | OpenCode server / CLI | Provider-managed |
| Cursor | ACP wrapper (`acp-agent`) | Provider-managed |
| Generic ACP | ACP wrapper | Provider-managed |
| Pi | Local Pi RPC process | Provider-managed |
| Mock load test | In-process fake | In-memory |
All providers:
@@ -182,6 +291,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)
@@ -194,12 +305,21 @@ All providers:
## Storage
`$PASEO_HOME` defaults to `~/.paseo`. The most important files:
```
$PASEO_HOME/
├── agents/{cwd-with-dashes}/{agent-id}.json # Agent state + config
├── agents/{cwd-with-dashes}/{agent-id}.json # Agent record + persisted timeline rows
├── projects/projects.json # Project registry
├── projects/workspaces.json # Workspace registry
── daemon.log # Daemon trace logs
── chat/ # Chat rooms
├── schedules/ # Scheduled-agent definitions and runs
├── loops/ # Loop runs and logs
├── config.json # Daemon config (mutable)
├── daemon-keypair.json # Daemon identity for relay/E2EE
├── push-tokens.json # Mobile push tokens
├── paseo.sock / paseo.pid # Local IPC socket and pidfile
└── daemon.log # Daemon trace logs (rotated)
```
## Deployment models

View File

@@ -1,188 +1,98 @@
# Coding Standards
These standards apply to all code changes: features, bug fixes, refactors, and performance work.
The core instinct: AI-generated code hedges — it covers every case, layers over instead of cutting in, scatters uncertainty everywhere, wraps in case. A senior engineer commits — to a shape, a boundary, a name, a happy path, a type — and lets everything else fall into place. Every rule below catches a different form of indecision.
For testing rules, see [testing.md](testing.md).
## Core principles
- **Zero complexity budget** — justify every abstraction with specific benefits
- **Fully typed TypeScript** — no `any`, no untyped boundaries
- **YAGNI** — build features and abstractions only when needed
- **Functional and declarative** over object-oriented
- **`interface`** over `type` when possible
- **`function` declarations** over arrow function assignments
- **Single-purpose functions** — one function, one job
- **Design for edge cases through types** rather than explicit handling
- **Don't catch errors** unless there's a strong reason to
- **No index.ts barrel files** that only re-export — they create unnecessary indirection
- **No "while I'm at it" improvements** — stay focused on the task
- **Zero complexity budget** — every abstraction must justify itself with a specific, current benefit.
- **YAGNI** — build features and abstractions only when needed. A function called once is indirection, not abstraction.
- **No "while I'm at it" cleanups** — make the change you came for. Drive-by edits hide in the diff.
- **Functional and declarative** over object-oriented.
- **`function` declarations** over arrow function assignments.
- **`interface`** over `type` when both work.
- **No `index.ts` barrel files** that only re-export — they create indirection and circular-dep risk. Import from the source.
## Type hygiene
## Comments and noise
### Infer from schemas
- Delete any comment where removing it loses zero information. Comments explain _why_, not _what_.
- No tutorial comments explaining language features (`// Use destructuring to...`).
- No decorative section dividers (`// ===== Helpers =====`). Use files and modules to organize, not ASCII art.
- No hedging comments (`// might need to revisit`, `// should work for most cases`). If you're unsure, investigate.
- No commented-out code. Git remembers.
- No `console.log` / `debugger` left behind. No `TODO: implement` stubs — if it needs to exist, write it.
Never hand-write a TypeScript type that can be inferred from a Zod schema.
## Confidence: commit to a shape
```typescript
// Bad: duplicate type that can drift
const schema = z.object({ procedure: z.string(), args: z.record(z.unknown()) });
type RPCArgs = { procedure: string; args: Record<string, unknown> };
- Validate at boundaries (network, IPC, user input, file I/O), trust types internally. After the parse, the value is what its type says.
- Every `?.` and `??` past the validation boundary is unconfident code — either the boundary should resolve it, or the type should reflect reality.
- No defensive checks for conditions the type system already rules out (`if (!agent) return` on a non-nullable parameter).
- No `try/catch` "just in case." If you can't say what you're catching and why, don't catch.
- Optionality is a design decision, not a migration shortcut. Distinct valid states → discriminated union. Intentionally empty → explicit `null`. Keep optionality at real boundaries.
// Good: infer from schema
type RPCArgs = z.infer<typeof schema>;
```
## Types
### Named types over inline
- No `any`. No `as` casts to bypass errors. No `@ts-ignore` / `@ts-expect-error`. Narrow with `if` / schema validation; let the compiler check harder, not less.
- If a Zod schema exists, the TypeScript type is `z.infer<typeof schema>`. Never hand-write a parallel type.
- One canonical type per concept. Layer-specific views are `Pick` / `Omit`, not duplicated fields.
- Name multi-property object shapes — no inline `Array<{ ... }>` or `Promise<{ ... }>` in signatures, returns, or generic args.
- Use string literal unions, not raw `string`, when the value is one of a known set. Catches typos at compile time.
- Object parameters past the obvious-name threshold: 3+ args, any boolean arg, any optional arg → object. `(thing, true, false, true)` is unreadable at the call site.
- Make impossible states impossible — discriminated unions over `{ isLoading; error?; data? }` bags.
No complex inline types in public function signatures.
## Errors
```typescript
// Bad
function enqueueJob(input: { userId: string; priority: "low" | "normal" | "high" }) {}
- Throw typed error classes that carry the fields a caller would want to read. Plain `Error("Provider X not found")` collapses structured info into a string.
- 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.
// Good
interface EnqueueJobInput {
userId: string;
priority: "low" | "normal" | "high";
}
function enqueueJob(input: EnqueueJobInput) {}
```
## Density
### Object parameters
- Nested ternaries are forbidden. A single ternary is fine only when both branches are a single identifier or trivial access (`x ? a : b`).
- Boolean expressions with 2+ clauses or mixed concerns → name the conditions.
- Object literals assemble pre-computed values; don't pack branching and lookups into property positions.
- Operations wrapping operations (`Object.fromEntries(arr.filter(...).map(...))`, `Math.max(...xs.map(...))`) → break into named intermediates.
- Max 3 levels of nesting (callbacks, JSX, control flow). Above that, extract.
If a function needs more than one argument, use a single object parameter.
## Structure and modules
```typescript
// Bad: positional args
function createToolCall(provider: string, toolName: string, payload: unknown) {}
- A directory is a module, not a namespace. One intentional public surface; internal files stay internal.
- Path is part of the name — prefer `provider/registry.ts` over `provider/provider-registry.ts`. If the filename has to do double duty, deepen the path.
- Filenames ending in `-utils`, `-helpers`, `-manager`, `-handler`, `-controller`, `-formatter`, `-builder` are a smell — the path didn't carry enough domain.
- Boundary returns answer the caller's question (`getActiveAgents()`), not "here's my storage" (`getAgents().filter(...)` repeated everywhere).
- One adapter means a hypothetical seam; two adapters means a real one. Don't define a port until something actually varies across it.
- Pass-through modules fail the deletion test — if removing the module makes callers go straight to what they wanted, delete it.
- Centralize policy. The same discriminator (`plan`, `provider`, `kind`, `status`) branched in 3+ files → policy table, not another `else if` per case.
- New features get a home before implementation. A feature smeared across 5 shared files is the same slop as a flat-peer namespace.
- Don't drop new files at the nearest root just because placement is unclear — say so and ask.
// Good: object param
interface CreateToolCallInput {
provider: string;
toolName: string;
payload: unknown;
}
function createToolCall(input: CreateToolCallInput) {}
```
## Refactoring is a bolt-on test
### One canonical type per concept
- A change should look like a thoughtful edit to existing code, not a new layer next to it. New coordinator wrapping a coordinator, new flag bypassing the normal path, new helper duplicating an existing selector — stop and reshape instead.
- Refactors preserve behavior by default. No removing features to simplify code without explicit approval.
- Have a verification plan _before_ refactoring — name the invariants, confirm a test holds them, write one if not. See [testing.md](testing.md).
- Migrate all callers and remove old paths in the same refactor. No fallback behavior unless explicitly designed.
Don't redefine the same concept in different layer-specific shapes (`RpcX`, `DbX`, `UiX`). Keep one canonical type and add explicit layer wrappers that reference it.
## React
```typescript
// Bad: duplicated fields across layers
type RpcToolCall = { toolName: string; args: Record<string, unknown>; requestId: string };
type DbToolCall = { toolName: string; args: Record<string, unknown>; id: string; createdAt: Date };
- `useEffect` is for synchronizing with external systems (DOM, network, timers, subscriptions). Not for transforming React state. Derived state → compute in render or `useMemo`.
- No effect cascades — chains of effects setting state that triggers more effects almost always want React Query or a reducer.
- `useRef` is for DOM refs and non-rendering identities (timer IDs, AbortController, latest-callback caches). If the value affects what renders next, it's state — model it explicitly with `useReducer` and a discriminated union.
- Server state goes through React Query. Manual `useState` + `useEffect` + `isLoading` + `error` for fetched data is always worse.
- 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.
- 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.
// Good: canonical type + wrappers
type ToolCall = { toolName: string; args: Record<string, unknown> };
type ToolCallRequest = { requestId: string; toolCall: ToolCall };
type ToolCallRecord = { id: string; createdAt: Date; toolCall: ToolCall };
```
## Naming
## Make impossible states impossible
Use discriminated unions instead of bags of booleans and optionals.
```typescript
// Bad
interface FetchState {
isLoading: boolean;
error?: Error;
data?: Data;
}
// Good
type FetchState =
| { status: "idle" }
| { status: "loading" }
| { status: "error"; error: Error }
| { status: "success"; data: Data };
```
## Optionality is a design decision
Don't mark fields optional to avoid migrations. Decide deliberately:
1. Is optionality actually needed?
2. If there are distinct valid states → discriminated union
3. If value can be intentionally empty → explicit `null`
4. Keep optionality at real boundaries (external input), then resolve it
## Validate at boundaries, trust internally
Parse external data once at the boundary with schema validation. Then use typed values everywhere else.
```typescript
// Bad: optional chaining because shape is unclear
const value = response?.data?.items?.[0]?.name;
// Good: validate at boundary, trust the types
const parsed = responseSchema.parse(rawResponse);
const value = parsed.data.items[0].name;
```
## Error handling
- **Fail explicitly** — if caller requests X and X is unavailable, throw rather than silently returning Y
- **Use typed domain errors** — not plain `Error`. Carry structured metadata for handling, logging, and user messaging
- **Preserve error semantics** — don't collapse meaningful typed errors into generic `Error`
```typescript
class TimeoutError extends Error {
constructor(
public readonly operation: string,
public readonly waitedMs: number,
) {
super(`${operation} timed out after ${waitedMs}ms`);
this.name = "TimeoutError";
}
}
```
## Keep logic density low
Avoid packing branching, lookup, and transformation into single dense expressions.
```typescript
// Bad: nested ternaries + inline lookups
const billing = shouldUseLegacy(account)
? getLegacy(account)
: buildBilling(
account,
rates.find((r) => r.region === account.region),
);
// Good: named steps, then assemble
const rate = rates.find((r) => r.region === account.region);
if (!rate) throw new MissingRateError(account.region);
const billing = shouldUseLegacy(account) ? getLegacy(account) : buildBilling(account, rate);
```
## Centralize policy
When the same discriminator (`plan`, `provider`, `kind`, `status`) is checked across multiple files, centralize it into a policy model. A new case should require editing one place, not many.
## React: keep components dumb
- Components render state and dispatch events — they don't compute transitions
- If a component has more than two interacting `useState` calls, extract a state machine or reducer
- `useRef` for mutable coordination state (flags, timers) is a smell — model states explicitly
- Never mirror a source of truth into local state; derive from it
- Test state logic as pure functions without rendering
## File organization
- Organize by domain first (`providers/claude/`), not by technical type (`tool-parsers/`)
- Name files after the main export (`create-toolcall.ts`)
- Use `index.ts` as an entrypoint, not a dumping ground
- Collocate tests with implementation (`thing.ts` + `thing.test.ts`)
## Refactoring contract
Refactoring is structure work, not feature work.
- Preserve behavior by default, especially user-facing behavior
- Do not remove features to simplify code without explicit approval
- Have a verification strategy before you start
- Fully migrate callers and remove old paths in the same refactor
- No fallback behavior by default — prefer explicit error over silent degradation
- Names describe meaning, not mechanics. `submitForm` over `handleOnClickButtonSubmit`. `running` over `filteredArrayOfRunningAgents`.
- The right length is the shortest unambiguous in context. Inside `AgentManager`, methods are `start`, `stop`, `list`.
- Match the surrounding code's vocabulary. If the codebase uses `getX`, don't introduce `fetchX` / `retrieveX` for the same shape.
- Don't leak implementation into names — `getAgent`, not `queryPostgresForAgent`. If swapping the impl would force a rename, the name is wrong.
- Booleans read as yes/no questions: `isX`, `hasX`, `canX`. Avoid negative booleans (`isNotConnected`).
- `data`, `result`, `info`, `manager`, `temp` are smells — say what the thing _is_.

View File

@@ -24,6 +24,7 @@ Provider IDs must be lowercase alphanumeric with hyphens (`/^[a-z][a-z0-9-]*$/`)
- [Extending a built-in provider](#extending-a-built-in-provider)
- [Z.AI (Zhipu) coding plan](#zai-zhipu-coding-plan)
- [Alibaba Cloud (Qwen) coding plan](#alibaba-cloud-qwen-coding-plan)
- [Codex with a custom OpenAI-compatible endpoint](#codex-with-a-custom-openai-compatible-endpoint)
- [Multiple profiles for the same provider](#multiple-profiles-for-the-same-provider)
- [Custom binary for a provider](#custom-binary-for-a-provider)
- [Disabling a provider](#disabling-a-provider)
@@ -34,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
{
@@ -59,6 +60,8 @@ Required fields for custom providers:
- `extends` — which built-in provider to inherit from (or `"acp"`)
- `label` — display name in the UI
See [Codex with a custom OpenAI-compatible endpoint](#codex-with-a-custom-openai-compatible-endpoint) below for the dedicated Codex example.
---
## Z.AI (Zhipu) coding plan
@@ -83,6 +86,7 @@ Required fields for custom providers:
"ANTHROPIC_BASE_URL": "https://api.z.ai/api/anthropic",
"API_TIMEOUT_MS": "3000000"
},
"disallowedTools": ["WebSearch"],
"models": [
{ "id": "glm-4.5-air", "label": "GLM 4.5 Air" },
{ "id": "glm-5-turbo", "label": "GLM 5 Turbo", "isDefault": true },
@@ -136,6 +140,7 @@ Required fields for custom providers:
"ANTHROPIC_AUTH_TOKEN": "sk-sp-<your-coding-plan-key>",
"ANTHROPIC_BASE_URL": "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic"
},
"disallowedTools": ["WebSearch"],
"models": [
{ "id": "qwen3.5-plus", "label": "Qwen 3.5 Plus", "isDefault": true },
{ "id": "qwen3-coder-next", "label": "Qwen 3 Coder Next" },
@@ -180,6 +185,62 @@ For pay-as-you-go, use `ANTHROPIC_API_KEY` with a standard Model Studio key (`sk
---
## Codex with a custom OpenAI-compatible endpoint
Codex talks to OpenAI's Responses API by default. Custom providers that extend `"codex"` can point Codex at any OpenAI-compatible endpoint (OpenRouter, LiteLLM, vLLM, llama.cpp server, an internal gateway, etc.) by setting `OPENAI_BASE_URL` and `OPENAI_API_KEY` in the provider `env`.
Paseo passes those variables through to the Codex app-server process **and** maps them into Codex's thread config under `model_provider` / `model_providers`, because Codex reads provider routing from config rather than from `OPENAI_BASE_URL` alone.
### Setup
```json
{
"agents": {
"providers": {
"my-codex": {
"extends": "codex",
"label": "My Codex",
"description": "Codex via custom OpenAI-compatible endpoint",
"env": {
"OPENAI_API_KEY": "sk-...",
"OPENAI_BASE_URL": "https://custom-relay.example.com"
},
"models": [{ "id": "custom-model", "label": "Custom Model", "isDefault": true }]
}
}
}
}
```
### What Paseo wires up
Under the hood, for each custom Codex provider Paseo injects this into Codex's config:
```toml
model_provider = "my-codex"
[model_providers.my-codex]
name = "My Codex"
base_url = "https://custom-relay.example.com/v1"
wire_api = "responses"
env_key = "OPENAI_API_KEY"
requires_openai_auth = false
```
- `base_url` — taken from `OPENAI_BASE_URL`. If it does not already end in `/v1`, Paseo appends `/v1`. Trailing slashes are stripped.
- `wire_api` — always `"responses"` (OpenAI Responses API protocol).
- `env_key` — set to `"OPENAI_API_KEY"` when that env var is present and non-empty, so Codex reads the key from the same env var Paseo passes through.
- `requires_openai_auth` — forced to `false` when `OPENAI_API_KEY` is provided, so Codex skips its built-in OpenAI login flow.
### Notes
- The endpoint must speak the OpenAI **Responses API**, not just chat completions. Many gateways (OpenRouter, LiteLLM) support both — pick the Responses-compatible route.
- Set `models` explicitly. Custom endpoints expose their own model IDs (`anthropic/claude-opus-4-7`, `qwen/qwen3-coder`, `local/llama`, etc.), and Paseo does not discover them automatically for Codex.
- To run multiple endpoints side-by-side, define multiple entries that each extend `"codex"` with different IDs, labels, and env. Each appears as its own provider in the app.
- If you only want to override the binary (e.g. a nightly Codex build) without changing the endpoint, omit `OPENAI_BASE_URL` and use `command` instead — see [Custom binary for a provider](#custom-binary-for-a-provider).
---
## Multiple profiles for the same provider
You can create multiple entries that extend the same built-in provider. Each gets its own entry in the provider list with independent credentials, models, and environment.
@@ -286,6 +347,41 @@ Override the command used to launch any provider with the `command` field. This
The `command` array completely replaces the default command for that provider. The binary must exist on the system — Paseo checks for its availability and will mark the provider as unavailable if not found.
### Pi-compatible forks with their own session directory
OMP already ships as a built-in provider option. It is disabled by default; enable it with:
```json
{
"agents": {
"providers": {
"omp": { "enabled": true }
}
}
}
```
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"
}
}
}
}
}
```
The session directory is used only for importing sessions that were started outside Paseo. Launching and resuming still go through the configured command, so this example resumes with `my-pi-fork --mode rpc --session <session-file>`.
---
## Disabling a provider
@@ -303,7 +399,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`.
---
@@ -313,6 +409,8 @@ The [Agent Client Protocol (ACP)](https://agentclientprotocol.com) is an open st
ACP agents communicate over JSON-RPC 2.0 on stdio. Paseo spawns the agent process and talks to it through stdin/stdout.
Paseo also ships an in-app ACP provider catalog for common agents, including CodeWhale, Cursor, DeepAgents, DimCode, Gemini CLI, Hermes, Qwen Code, and Kimi Code. Catalog entries create the same `extends: "acp"` provider config shown below.
### Adding a generic ACP provider
Set `extends: "acp"` and provide a `command`:
@@ -340,11 +438,38 @@ 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
}
}
}
}
}
```
### 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.
For package-runner commands such as `npx -y @google/gemini-cli --acp`, the version probe keeps the package spec and runs `npx -y @google/gemini-cli --version`. This diagnoses the actual agent package instead of only proving that `npx` exists.
ACP probes use short timeouts and browser-suppression environment variables so agents that enter an auth/browser flow fail as a diagnostic error instead of hanging the provider screen.
### Example: Google Gemini CLI
[Gemini CLI](https://github.com/google-gemini/gemini-cli) supports ACP via the `--acp` flag.
1. Install: `npm install -g @anthropic-ai/gemini-cli` or see [Gemini CLI docs](https://github.com/google-gemini/gemini-cli)
1. Install: `npm install -g @google/gemini-cli` or see [Gemini CLI docs](https://github.com/google-gemini/gemini-cli)
2. Authenticate with Google (Gemini CLI handles its own auth)
3. Add to config.json:
@@ -468,18 +593,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
@@ -502,6 +628,12 @@ Each entry in the `models` array:
| `description` | `string` | No | Short description |
| `isDefault` | `boolean` | No | Mark as the default thinking option |
### Claude settings.json model discovery
The built-in `claude` provider appends concrete model IDs from `~/.claude/settings.json` to its first-party Claude model list. Paseo reads the top-level `model` field and these `env` keys: `ANTHROPIC_MODEL`, `ANTHROPIC_SMALL_FAST_MODEL`, `ANTHROPIC_DEFAULT_OPUS_MODEL`, `ANTHROPIC_DEFAULT_SONNET_MODEL`, and `ANTHROPIC_DEFAULT_HAIKU_MODEL`.
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
When a custom provider extends `"claude"` but points `ANTHROPIC_BASE_URL` at a non-Anthropic API (Z.AI, Alibaba/Qwen, proxies), the Claude Agent SDK may try to use Anthropic-only server-side tools like `WebSearch`. Third-party APIs don't support these tools, causing errors.
@@ -527,7 +659,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`)
@@ -550,6 +682,7 @@ A config.json with multiple custom providers:
"ANTHROPIC_BASE_URL": "https://api.z.ai/api/anthropic",
"API_TIMEOUT_MS": "3000000"
},
"disallowedTools": ["WebSearch"],
"models": [
{ "id": "glm-4.5-air", "label": "GLM 4.5 Air" },
{ "id": "glm-5-turbo", "label": "GLM 5 Turbo", "isDefault": true },
@@ -564,6 +697,7 @@ A config.json with multiple custom providers:
"ANTHROPIC_AUTH_TOKEN": "sk-sp-<coding-plan-key>",
"ANTHROPIC_BASE_URL": "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic"
},
"disallowedTools": ["WebSearch"],
"models": [
{ "id": "qwen3.5-plus", "label": "Qwen 3.5 Plus", "isDefault": true },
{ "id": "qwen3-coder-next", "label": "Qwen 3 Coder Next" }

View File

@@ -1,6 +1,6 @@
# Data Model
Paseo uses **file-based JSON persistence** instead of a traditional database. All data is validated at runtime with Zod schemas and written atomically (write to temp file, then rename). There are no migrations — schemas use optional fields with defaults for forward compatibility.
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`).
@@ -11,8 +11,12 @@ All server-side stores live under `$PASEO_HOME` (defaults to `~/.paseo`).
```
$PASEO_HOME/
├── config.json # Daemon configuration
├── server-id # Stable daemon identifier (plain text, "srv_<base64url>")
├── daemon-keypair.json # E2EE keypair for relay (mode 0600)
├── paseo.pid # Daemon PID lock file
├── daemon.log # Default log file (path configurable)
├── agents/
│ └── {project-dir}/
│ └── {sanitized-cwd}/
│ └── {agentId}.json # One file per agent
├── schedules/
│ └── {scheduleId}.json # One file per schedule
@@ -23,9 +27,14 @@ $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). Persistent server stores write atomically by writing a temp file in the target directory and then renaming it into place.
---
## 1. Agent Record
@@ -34,28 +43,30 @@ $PASEO_HOME/
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 `{}`) |
| `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 |
| `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 `create_agent` subagent relationships — 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 |
### Nested: SerializableConfig
@@ -114,11 +125,19 @@ Each agent is stored as a separate JSON file, grouped by project directory.
| `description` | `string?` |
| `tooltip` | `string?` |
| `icon` | `string?` |
| `value` | `string?` |
| `value` | `string \| null` |
| `options` | `AgentSelectOption[]` |
---
## 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`
@@ -130,29 +149,36 @@ Single file, validated with `PersistedConfigSchema`.
version: 1,
daemon: {
listen: "127.0.0.1:6767",
hostnames: true | string[],
mcp: { enabled: boolean },
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[] },
relay: { enabled: boolean, endpoint: string, publicEndpoint: string }
relay: { enabled: boolean, endpoint: string, publicEndpoint: string, useTls: boolean, publicUseTls: boolean },
auth: { password: string } // bcrypt hash, optional
},
app: {
baseUrl: string
},
worktrees?: {
root?: string // optional root for new worktrees; defaults to $PASEO_HOME/worktrees
},
providers: {
openai: { apiKey: string },
openai: { voice: { apiKey: string, baseUrl: string } },
local: { modelsDir: string }
},
agents: {
providers: {
[provider: string]: {
command: { mode: "default" } | { mode: "append", args: string[] } | { mode: "replace", argv: string[] },
env: Record<string, string>
}
// ProviderOverrideSchema; legacy entries with `command: { mode, ... }` are migrated to the
// current shape on load via `migrateProviderSettings`. Custom provider IDs must declare
// `extends` (one of the built-ins or `"acp"`) and `label`. See `provider-launch-config.ts`.
providers: Record<providerId, ProviderOverride>,
metadataGeneration: {
providers: [{ provider, model?, thinkingOptionId? }]
}
},
features: {
dictation: { enabled, stt: { provider, model, confidenceThreshold } },
voiceMode: { enabled, llm, stt, turnDetection, tts: { provider, model, voice, speakerId, speed } }
dictation: { enabled, stt: { provider, model, language, confidenceThreshold } },
voiceMode: { enabled, llm, stt: { provider, model, language }, turnDetection, tts: { provider, model, voice, speakerId, speed } }
},
log: {
level, format,
@@ -164,6 +190,41 @@ Single file, validated with `PersistedConfigSchema`.
All fields are optional with sensible defaults.
`agents.metadataGeneration.providers` controls the preferred structured-generation fallback order for daemon-side metadata tasks such as commit messages, PR text, branch names, and generated agent titles. Entries are tried first in the configured order, then Paseo falls through to dynamically discovered defaults and finally the current selection when available.
Local speech model ids are intentionally narrow: STT uses `parakeet-tdt-0.6b-v2-int8`, TTS uses `kokoro-en-v0_19`, and turn detection uses the bundled Silero VAD model.
Set these to select OpenAI instead of local speech:
| 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 voice can be configured under `providers.openai`:
```json
{
"providers": {
"openai": {
"voice": {
"apiKey": "sk-...",
"baseUrl": "https://api.openai.com/v1"
}
}
}
}
```
`providers.openai.voice.apiKey` and `providers.openai.voice.baseUrl` apply only to Paseo OpenAI voice features.
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
@@ -192,7 +253,7 @@ One file per schedule. ID is 8 hex characters.
### Nested: ScheduleCadence (discriminated union on `type`)
- `{ type: "every", everyMs: number }` — interval in milliseconds
- `{ type: "cron", expression: string }` — cron expression
- `{ type: "cron", expression: string, timezone?: string }` — cron expression; absent `timezone` means UTC, present `timezone` is an IANA time zone used for local wall-clock recurrence
### Nested: ScheduleTarget (discriminated union on `type`)
@@ -255,7 +316,7 @@ Single file containing all rooms and messages.
**Path:** `$PASEO_HOME/loops/loops.json`
Single file containing an array of all loop records.
Single file containing an array of all loop records. Writes are direct (not atomic) and serialized through an in-memory queue. On daemon startup any record with `status: "running"` is recovered as `"stopped"` with an interruption log entry.
| Field | Type | Description |
| ----------------------- | --------------------------------------------------- | ------------------------------------------ |
@@ -265,10 +326,12 @@ Single file containing an array of all loop records.
| `cwd` | `string` | Working directory |
| `provider` | `string` | Default provider |
| `model` | `string?` | Default model |
| `modeId` | `string?` | Default mode ID |
| `workerProvider` | `string?` | Override provider for workers |
| `workerModel` | `string?` | Override model for workers |
| `verifierProvider` | `string?` | Override provider for verifiers |
| `verifierModel` | `string?` | Override model for verifiers |
| `verifierModeId` | `string?` | Override mode ID for verifiers |
| `verifyPrompt` | `string?` | LLM verification prompt |
| `verifyChecks` | `string[]` | Shell commands to run as checks |
| `archive` | `boolean` | Whether to archive worker agents after use |
@@ -344,15 +407,20 @@ Single file containing an array of all loop records.
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?` (ISO 8601) | Soft-delete timestamp |
| 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 |
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.
---
@@ -362,16 +430,25 @@ Array of project records.
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?` (ISO 8601) | Soft-delete timestamp |
| 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 |
| `cwd` | `string` | Filesystem path |
| `kind` | `"local_checkout" \| "worktree" \| "directory"` | |
| `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 worktree's git branch. Separate from `displayName`/`title`; only worktree workspaces set it. A branch rename writes this and never the name. |
| `createdAt` | `string` (ISO 8601) | |
| `updatedAt` | `string` (ISO 8601) | |
| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete; required nullable |
> **Opaque-ID invariant:** `workspaceId` is opaque identity, never a filesystem path. Filesystem and git operations take `cwd`/`workspaceDirectory` only — never the id. Path-derived grouping keys (e.g. `deriveWorkspaceDirectoryKey`, used at bootstrap to group agents into a workspace) are directory keys, not workspace identity, and must not be persisted or compared as ids.
`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.
---
@@ -385,7 +462,20 @@ Array of workspace records. A workspace is a specific working directory within a
}
```
Simple set of Expo push notification tokens. No schema validation — just an array of strings.
Simple set of Expo push notification tokens. Loaded with permissive parsing (filters non-string entries). Persisted with atomic temp-file rename.
---
## 9. Daemon meta files
These small files are not validated as full Zod schemas but are persisted under `$PASEO_HOME` for daemon identity and runtime coordination.
| Path | Format | Notes |
| --------------------- | -------------------------------------------------------------- | --------------------------------------------------------------------------------- |
| `server-id` | Plain text, e.g. `srv_<base64url>` | Stable per-`$PASEO_HOME` daemon ID. Overridable via `PASEO_SERVER_ID` env. |
| `daemon-keypair.json` | `{ v: 2, publicKeyB64, secretKeyB64 }` (libsodium box keypair) | E2EE relay identity. Written with mode `0600`. Regenerated if file is unreadable. |
| `paseo.pid` | JSON `{ pid, startedAt, ... }` | PID lock; prevents two daemons sharing one `$PASEO_HOME`. |
| `daemon.log` | Pino log output | Default location; path/rotation configurable via `log.file` in `config.json`. |
---
@@ -393,6 +483,13 @@ Simple set of Expo push notification tokens. No schema validation — just an ar
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

@@ -1,247 +0,0 @@
# Design system
Tokens — every color, font size, weight, spacing step, radius, icon size — live in `packages/app/src/styles/theme.ts`.
---
## 1. Character
Paseo is minimal, spacious, quiet, confident. Whitespace is deliberate. Nothing crowds, nothing decorates, nothing apologizes. A row, a label, a control. That is the bar.
The app is calm so the user's work is not. Every visual decision serves either _act on this_ or _understand this_ — never _look at this_.
Consistency comes from component reuse, not from hand-matching styles across surfaces. A row in the projects list, a row in settings, and a row in a modal are the same component, not three implementations that happen to look alike. When two surfaces do the same semantic thing in two different ways, one of them is wrong.
---
## 2. Component reuse
A semantic element used in three or more places is a primitive. One of a kind is a screen.
Primitives live in `packages/app/src/components/ui/` and `packages/app/src/components/headers/`. Card and row layout live in `packages/app/src/styles/settings.ts`. Section structure lives in `packages/app/src/screens/settings/settings-section.tsx`.
A pressable styled to look like a button is wrong; the button is `<Button>` (`packages/app/src/components/ui/button.tsx`). A bare `<Text>` styled to look like a section header is wrong; the section header is `<SettingsSection>` (`packages/app/src/screens/settings/settings-section.tsx`). A custom `Modal` for a confirmation is wrong; the confirmation is `confirmDialog` (`packages/app/src/utils/confirm-dialog.ts`). A hand-rolled overflow menu is wrong; the menu is `<DropdownMenu>` (`packages/app/src/components/ui/dropdown-menu.tsx`). A hand-rolled status pill is wrong; the pill is `<StatusBadge>` (`packages/app/src/components/ui/status-badge.tsx`).
Before adding a new component, read `components/ui/`. The primitive usually exists.
---
## 3. Hierarchy
Hierarchy is conveyed through weight and color, not size. Most labels, titles, and hints across the app are `fontSize.base` or `fontSize.xs`. The distinction between a row's primary line and its secondary line is `foreground` versus `foregroundMuted`.
Weight has three tiers, applied by role:
- **Screen titles** — the title at the top of a screen — use `<ScreenTitle>` (`packages/app/src/components/headers/screen-title.tsx:31-34`), which renders `fontSize.base` at weight `400` on compact and `300` on desktop. Top-of-screen titles are lighter on desktop, not heavier. The workspace screen header follows the same rule (`packages/app/src/screens/workspace/workspace-screen.tsx:3052-3057`).
- **Structural labels** use `fontWeight.medium`. This applies to section labels above a stack of rows (`packages/app/src/components/agent-list.tsx:519-523`, `packages/app/src/components/keyboard-shortcuts-dialog.tsx:63-67`), form field labels above an input inside a modal (`packages/app/src/components/add-host-modal.tsx:19-23`, `packages/app/src/components/pair-link-modal.tsx:24-28`), the title at the top of a modal/sheet/dialog (`packages/app/src/components/adaptive-modal-sheet.tsx:90-94`, `packages/app/src/components/ui/combobox.tsx:1607-1611`, `packages/app/src/components/welcome-screen.tsx:48-53`), action button labels in tight components such as the sidebar callout actions (`packages/app/src/components/sidebar-callout.tsx:218-221`), and inline data emphasis on dense metadata rows (`packages/app/src/components/git-diff-pane.tsx:2322-2327`, `packages/app/src/components/file-explorer-pane.tsx:1115-1122`).
- **Content** uses `fontWeight.normal`. This applies to settings rows (`packages/app/src/styles/settings.ts`), sidebar primary list-item titles (`packages/app/src/components/sidebar-workspace-list.tsx:2680-2686`, `packages/app/src/components/agent-list.tsx:572-578`), `<Button>` text (`packages/app/src/components/ui/button.tsx:80-84`), `<StatusBadge>` text (`packages/app/src/components/ui/status-badge.tsx:56-60`), and `<SidebarCallout>` titles (`packages/app/src/components/sidebar-callout.tsx:175-180`).
The rule, condensed: text that _names_ a surface or a group is `medium`. Text that lives _inside_ a surface or a group is `normal`. Top-of-screen titles are `<ScreenTitle>`, which is lighter still.
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.
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.
---
## 4. Buttons
The button is `<Button>` (`packages/app/src/components/ui/button.tsx`). It has five variants. Each has one job.
`default` is the one primary action on a surface — filled with `accent`. At most one per page. The primary slot inside an `<AdaptiveModalSheet>` and the highlighted action on the welcome screen are the canonical uses.
`secondary` is the paired action when two actions carry equal weight — filled with `surface3`. The component default is `secondary`, which matches its frequency in the codebase.
`outline` is the low-frequency action that lives on a row — transparent with `borderAccent`. Restart, Remove, Update on host detail (`packages/app/src/screens/settings/host-page.tsx:585-594`).
`ghost` is structural and non-committal — no border, no fill. Back arrows, header toggles, "Load more" footers (`packages/app/src/screens/sessions-screen.tsx:54-63`), more-affordances. Ghost is used when the affordance is part of the chrome, not a decision.
`destructive` is filled with `destructive`. It only appears inside a confirm. The button on the page is `outline`; the destructive button is the confirm button inside the dialog.
Sizes: `sm` for any button sitting in a row. `md` is the page default. `lg` is reserved for large standalone CTAs.
A `<Pressable>` wrapping a `<Text>` is a sixth variant. It is wrong. `<Button>` accepts `style`, `textStyle`, `leftIcon`, `disabled`, `size`, and `variant`.
---
## 5. Borders
Borders group, separate, or rarely emphasize.
A logical block of related rows lives inside a card — one border around the whole group. The card primitive is `settingsStyles.card`; the keyboard-shortcuts dialog uses the same shape inline (`packages/app/src/components/keyboard-shortcuts-dialog.tsx:68-73`). The border defines what belongs together.
Rows after the first inside a card carry `settingsStyles.rowBorder` — a single top border. The first row never has one. The same divider pattern appears in the keyboard-shortcuts dialog rows (`packages/app/src/components/keyboard-shortcuts-dialog.tsx:74-83`). Rows do not need their own background to feel separated.
A list that is itself the page content — sidebar items in `sidebar-workspace-list.tsx`, the workspace list, the agent list (`packages/app/src/components/agent-list.tsx`) — uses spacing and surface, not borders, to separate items. Rows-in-a-card is an interior pattern; lists-as-pages are not.
Pane chrome — the workspace pane header, the file-explorer header, the diff pane header — uses a single bottom border to separate the header from the content (`packages/app/src/components/git-diff-pane.tsx:2328-2331`). One border, no shadow.
`borderAccent` is reserved for the outline button. Inputs use `border`. Single-thing borders are wrong; a single bordered element is either a card with one row (use the card) or it does not need a border.
---
## 6. Pickers
Five primitives. The pick is determined by option count, the need to search, and how the picker is anchored.
`<DropdownMenu>` is for a small fixed set anchored to a trigger. Theme picker, kebab menus on workspace and project rows (`packages/app/src/components/sidebar-workspace-list.tsx:684-770`), row "more" menus. Items can be async (`status: "pending"`) and can include destructive entries. Under ~10 options where the user knows what they're looking for.
`<Combobox>` is for a large or searchable list. Host switcher in the sidebar footer, model selector in the composer, branch switcher in the workspace header (`packages/app/src/components/branch-switcher.tsx`). The user types to find the option, or the list is long enough to scroll.
`<ContextMenu>` is for right-click and long-press on a target. The row is the trigger; there is no visible affordance. Used for incidental actions on workspace rows in the sidebar (`packages/app/src/components/sidebar-workspace-list.tsx`).
`<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.
`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`.
---
## 7. Density and rhythm
Settings detail pages, the projects detail page, and any list+detail content sit inside a centered, max-width 720 column (`packages/app/src/screens/settings-screen.tsx:1056-1062`, `packages/app/src/screens/projects-screen.tsx`). Lines stay readable, the eye does not have to track wide horizontal distances. Form modals carry their own narrower content frame (`packages/app/src/components/add-host-modal.tsx`).
Workspace and chat surfaces use the full width — these are working surfaces, not reading surfaces. The composer carries `MAX_CONTENT_WIDTH` from `packages/app/src/constants/layout.ts` to keep lines readable while letting the workspace pane fill the rest.
Sections sit apart. `<SettingsSection>` owns its own bottom margin; the next thing is wrapped in another `<SettingsSection>`. The agent-list `sectionHeading` carries the same `marginTop`/`marginBottom` rhythm (`packages/app/src/components/agent-list.tsx:511-517`). Adding `marginBottom` to a section is wrong.
Cards inside a section sit closer than sections. Rows inside a card touch — only the divider separates them. The rhythm is page → spacious; section → spacious; card → tight.
Rows have generous vertical padding: roughly 16px of content plus 16px of vertical padding for settings rows, 812px for sidebar list items where many rows must fit. Compressing rows below the established density to fit more on the screen is wrong. Too many rows means more cards or more sections, not smaller rows.
The whitespace is the design.
---
## 8. Responsiveness
Compact-first. The small case is designed; the large case adds chrome around it.
The list+detail pattern is canonical and reused across surfaces. The settings shell (`packages/app/src/screens/settings-screen.tsx`) and the projects screen (`packages/app/src/screens/projects-screen.tsx`) implement it identically:
- On compact: full-screen list with `<BackHeader>` at the top. Tapping a row pushes a full-screen detail with its own `<BackHeader>` that returns to the list.
- On desktop: a 320px sidebar on the left holds the list with `surfaceSidebar` background. The content pane on the right holds the selected detail with `<ScreenHeader>`, `<HeaderIconBadge>`, and `<ScreenTitle>`.
The branching is one `useIsCompactFormFactor()` check at the top of the screen component. The list and the detail are the same components in both layouts; only the framing changes.
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.
A new list+detail feature copies the settings shell. A new workspace-shaped feature copies the workspace shell. Inventing a third shape happens in design review, not in a PR.
---
## 9. Copy and voice
Sentence case. "Pair a device", "Danger zone", "Restart daemon", "Inject Paseo tools", "No sessions yet", "Load more". Proper nouns retain casing — Paseo, Beta, Stable, Local. Title case is wrong.
No trailing periods on row titles, labels, or buttons. No trailing period on a single-clause hint: "What happens when you press Enter while the agent is running" (`packages/app/src/screens/settings-screen.tsx:271-272`). Periods exist inside multi-sentence prose: "Restarts the daemon process. The app will reconnect automatically."
Empty-state strings are short noun phrases or short sentences: "No projects yet", "Select a project", "No sessions yet" (`packages/app/src/screens/sessions-screen.tsx:74-76`), "Host not found".
Buttons are imperative: Save, Cancel, Restart, Remove, Update, Install update, Add host, Load more. In-flight labels are present-participle with a literal three-dot ellipsis: "Saving...", "Restarting...", "Removing...", "Loading...".
Error copy is direct. "Unable to remove host" (`packages/app/src/screens/settings/host-page.tsx:697`), not "Sorry, we couldn't remove the host." Recovery instructions are concrete: "Wait for it to come online before restarting." Errors describe state; they do not editorialize.
Terminology:
- Workspace, never "checkout".
- Host, except where the user-facing concept is the daemon process itself ("Restart daemon").
- Project, not "repo" or "repository".
- Provider, not "model provider".
- Session and agent are distinct: a session is a historical entry in `sessions-screen.tsx`; an agent is a live entity in the workspace.
---
## 10. States
Loading is inline by default. `<LoadingSpinner size={14} color={foregroundMuted} />` sits next to the thing it relates to (`packages/app/src/screens/settings/providers-section.tsx:227-231`). Page-level loading is a centered `<LoadingSpinner size="large">` (`packages/app/src/screens/sessions-screen.tsx:69-72`). Card-level loading is a single short line, not a spinner. In-row dropdown items use `<DropdownMenuItem status="pending" pendingLabel="Removing...">`; the menu item handles its own pending state.
Empty states are short noun phrases. Centered, muted, one or two lines. Sessions screen pairs the empty noun with a single ghost button to navigate back (`packages/app/src/screens/sessions-screen.tsx:74-81`); that pairing is the maximum elaboration. Illustrations and CTAs disguised as empty states are wrong.
Inline errors are a single sentence in `palette.red[300]` `xs`, sitting under the field or inside the card it relates to (`packages/app/src/screens/settings/providers-section.tsx:115-119`).
Page-level alerts — informational notices, success confirmations, warnings, or recoverable errors that need a small visible block on the page — use `<Alert>` (`packages/app/src/components/ui/alert.tsx`). Variants: `default`, `info`, `success`, `warning`, `error`. The chrome is quiet by design: a 1px tinted border, transparent background, a small variant-tinted icon, the title in the variant accent, the description in `foregroundMuted`. Actions go in the `children` slot as `<Button variant="outline" size="sm">` — recovery actions are low-frequency and outline keeps them quiet alongside the alert's accent (`packages/app/src/screens/project-settings-screen.tsx`). One `<Alert>` at a time per region.
Sidebar callouts — cross-cutting alerts that apply across the whole app, like daemon version mismatch and desktop update available — register through `useSidebarCallouts()` and render in the left sidebar via `<SidebarCallout>` (`packages/app/src/components/sidebar-callout.tsx`). The chrome (top-border-only, full-width action buttons) is tuned for that ~280px column. Canonical sources: `packages/app/src/components/daemon-version-mismatch-callout-source.tsx`, `packages/app/src/desktop/updates/update-callout-source.tsx`. Never import `<SidebarCallout>` into a page — that's what `<Alert>` is for.
Imperative errors are `Alert.alert("Error", "Unable to ...")` (the React Native `Alert` API, not this component) for failures that interrupt the flow and have no place on the page.
Disabled state is `opacity: theme.opacity[50]` on the outer pressable. Color changes for disabled state are wrong; a disabled button is the same button, dimmer.
Partial failure (a list mostly fine but one source errored) is a bordered banner above the list, listing each failure in red-300 `xs` (`packages/app/src/screens/projects-screen.tsx:151-159`). The list still renders.
State surfaces at the smallest scope it affects. Field error stays under the field; page error is a banner; flow-stopping error is an `Alert`.
---
## 11. List rows
The row anatomy is a content column with an optional trailing slot. Inside a card the row is `settingsStyles.row`. Inside a sidebar list the row carries its own padding and `borderRadius.lg` per item (`packages/app/src/components/sidebar-workspace-list.tsx:2614-2625`).
Rows that drill into a detail lead with a chevron in the trailing slot (`ChevronRight`, `iconSize.sm`, `foregroundMuted`). The whole row is the `<Pressable>`. Pair-device row (`packages/app/src/screens/settings/host-page.tsx:644-668`), provider row (`packages/app/src/screens/settings/providers-section.tsx:92-132`), project row in the projects list. Chevron means navigation.
Kebab menus (`<DropdownMenu>` with `<MoreVertical size={14} />` trigger) are for actions on the row, not navigation. Trigger style: `padding: 2`, `borderRadius: 4`, hover background `surface2`. Menu position: `align="end"`. Items use `<DropdownMenuItem leading={<Icon size={14} color={foregroundMuted} />} ...>`. Visibility is `isHovered || isTouchPlatform` — hover-revealed on web, always visible on native (`packages/app/src/components/sidebar-workspace-list.tsx:684-770`).
A row may carry both a chevron and a kebab when both navigation and row-level actions apply. Chevron sits at the end; kebab sits before it.
Switches and segmented controls also sit in the trailing slot. A row that both navigates and toggles is a `<Pressable>` with a `<Switch>` in the trailing slot — the switch calls `event.stopPropagation()` so the row press does not fire (`packages/app/src/screens/settings/providers-section.tsx:92-132`). Sidebar items that hold a status dot, a count, and a kebab follow the same rule (`packages/app/src/components/sidebar-workspace-list.tsx`).
Selected state on rows in a desktop list+detail uses `surfaceSidebarHover` as the background (`packages/app/src/screens/projects-screen.tsx`). Selected state on rows in the sidebar list uses `surface2` (`packages/app/src/components/agent-list.tsx:563-571`).
---
## 12. Status pills and badges
Status pills are `palette.<color>[300]` foreground on a 10%-alpha background of the same color. Success uses green, warning uses amber, danger uses red, muted uses zinc. The `<StatusBadge>` primitive (`packages/app/src/components/ui/status-badge.tsx`) is canonical.
Status dots — the small filled circles next to a host or agent name — are `borderRadius.full` filled with the status color (`statusSuccess`, `statusWarning`, `statusDanger`, or `foregroundMuted`). They sit in the trailing slot of a sidebar row or as a leading marker on a status pill.
The bespoke pills in `packages/app/src/screens/settings/host-page.tsx:97-116`, `packages/app/src/components/agent-list.tsx:607-632`, and `packages/app/src/components/sidebar-workspace-list.tsx:2889-2894` are drift to be removed. New code uses `<StatusBadge>`.
---
## 13. Forbidden
- `fontWeight.medium` on row titles, body text, button labels, badge text, or `<SidebarCallout>` titles. Medium is reserved for the structural-label tier described in §3 — section labels, modal/sheet titles, dense metadata emphasis, and tight action labels. Anything else is `normal`. `<ScreenTitle>` is responsive `400/300` and is never overridden.
- `<Pressable>` wrapping `<Text>` to make a button. `<Button>` exists.
- Bare `<Text>` for a section header inside settings. `<SettingsSection>` exists.
- A "Settings" CTA on a detail page. Detail pages are settings; settings is reached from the sidebar, the host entry, or a row's kebab menu.
- The word "checkout" in UI strings or identifiers. The term is "workspace".
- New color tokens or hardcoded hex outside the palette. Status pill rgba backgrounds are the documented pattern (§12), not a license.
- Placeholder text dimmed beyond `foregroundMuted`. No extra opacity, no italics, no ghost-text.
- `onPointerEnter` and `onPointerLeave`. They do not fire on native iOS. Hover uses Pressable's `onHoverIn`/`onHoverOut` gated with `isHovered || isCompact || isNative`.
- Raw DOM APIs without an `isWeb` guard.
- Spacing values outside the scale. `padding: 20` and `gap: 10` are wrong.
- Color changes for disabled state. Opacity only.
- Destructive actions without `confirmDialog`. Restart, remove, archive, and any future destructive action are confirmed.
- 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.
---
## 14. Canonical surfaces by pattern
| Pattern | Reference |
| --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| List+detail (compact stack, desktop sidebar+pane) | `packages/app/src/screens/settings-screen.tsx`, `packages/app/src/screens/projects-screen.tsx` |
| Detail card+row | `packages/app/src/screens/settings/host-page.tsx`, `packages/app/src/screens/settings/providers-section.tsx` |
| Section grouping inside a card list | `packages/app/src/screens/settings/settings-section.tsx` |
| Form modal (label + input fields, primary + cancel) | `packages/app/src/components/add-host-modal.tsx`, `packages/app/src/components/pair-link-modal.tsx`, `packages/app/src/components/project-picker-modal.tsx` |
| Destructive confirmation | `confirmDialog` invoked from `packages/app/src/screens/settings/host-page.tsx:541-547` |
| Centered hero / first-run | `packages/app/src/components/welcome-screen.tsx` |
| Sidebar list (workspaces, hosts) | `packages/app/src/components/sidebar-workspace-list.tsx`, `packages/app/src/components/left-sidebar.tsx` |
| Live list of items with sections (agents) | `packages/app/src/components/agent-list.tsx` |
| Historical list (sessions) | `packages/app/src/screens/sessions-screen.tsx` |
| Workspace pane (multi-tab, split) | `packages/app/src/screens/workspace/workspace-screen.tsx` |
| Composer / message input | `packages/app/src/components/composer.tsx`, `packages/app/src/components/message-input.tsx` |
| Pane chrome with single bottom border | `packages/app/src/components/git-diff-pane.tsx`, `packages/app/src/components/file-explorer-pane.tsx`, `packages/app/src/components/terminal-pane.tsx` |
| Page-level alert (info / success / warning / error) | `packages/app/src/components/ui/alert.tsx`, `packages/app/src/screens/project-settings-screen.tsx` |
| Sidebar callout (cross-cutting alert) | `packages/app/src/components/sidebar-callout.tsx`, `packages/app/src/contexts/sidebar-callout-context.tsx`, `packages/app/src/components/daemon-version-mismatch-callout-source.tsx`, `packages/app/src/desktop/updates/update-callout-source.tsx` |
| Searchable picker | `packages/app/src/components/ui/combobox.tsx`, `packages/app/src/components/branch-switcher.tsx` |
| Trigger-anchored menu | `packages/app/src/components/ui/dropdown-menu.tsx` (used in `sidebar-workspace-list.tsx`, theme picker) |
| Right-click / long-press menu | `packages/app/src/components/ui/context-menu.tsx` (used in `sidebar-workspace-list.tsx`) |
| Headers (back, screen, menu) | `packages/app/src/components/headers/back-header.tsx`, `screen-header.tsx`, `menu-header.tsx` |

View File

@@ -1,73 +1,249 @@
# Designing Features
# Design
How to think through a feature before writing code.
Tokens — every color, font size, weight, spacing step, radius, icon size — live in `packages/app/src/styles/theme.ts`.
## Start from the user
---
Even for backend work, start from the user's perspective:
## 1. Character
- What problem does this solve?
- What triggers it? User action, schedule, event?
- What does success look like from the user's perspective?
- What data does it need? Where does that data come from?
Paseo is minimal, spacious, quiet, confident. Whitespace is deliberate. Nothing crowds, nothing decorates, nothing apologizes. A row, a label, a control. That is the bar.
## Map existing code
The app is calm so the user's work is not. Every visual decision serves either _act on this_ or _understand this_ — never _look at this_.
Before designing anything new, understand what exists:
Consistency comes from component reuse, not from hand-matching styles across surfaces. A row in the projects list, a row in settings, and a row in a modal are the same component, not three implementations that happen to look alike. When two surfaces do the same semantic thing in two different ways, one of them is wrong.
- Where does similar functionality live?
- What patterns does the codebase already use?
- What layers exist? (See [architecture.md](./architecture.md))
- What types and data shapes are already defined?
---
New features rarely mean only new code. Usually they require modifying existing interfaces, extending existing types, or refactoring to accommodate the new functionality. Identify what needs to change, not just what needs to be added.
## 2. Component reuse
## Define verification before implementation
A semantic element used in three or more places is a primitive. One of a kind is a screen.
Before designing the solution, define how you'll know it works:
Primitives live in `packages/app/src/components/ui/` and `packages/app/src/components/headers/`. Card and row layout live in `packages/app/src/styles/settings.ts`. Section structure lives in `packages/app/src/screens/settings/settings-section.tsx`.
- What tests will prove this feature is correct?
- At what layer? Unit, integration, E2E?
- What's the simplest way to verify the core behavior?
A pressable styled to look like a button is wrong; the button is `<Button>` (`packages/app/src/components/ui/button.tsx`). A bare `<Text>` styled to look like a section header is wrong; the section header is `<SettingsSection>` (`packages/app/src/screens/settings/settings-section.tsx`). A custom `Modal` for a confirmation is wrong; the confirmation is `confirmDialog` (`packages/app/src/utils/confirm-dialog.ts`). A hand-rolled overflow menu is wrong; the menu is `<DropdownMenu>` (`packages/app/src/components/ui/dropdown-menu.tsx`). A hand-rolled status pill is wrong; the pill is `<StatusBadge>` (`packages/app/src/components/ui/status-badge.tsx`).
If you can't define verification, you don't understand the feature well enough yet.
Before adding a new component, read `components/ui/`. The primitive usually exists.
## Design the shape
---
### Data
## 3. Hierarchy
- What types are needed?
- Use discriminated unions — make impossible states impossible
- One canonical type per concept (see [coding-standards.md](./coding-standards.md))
Hierarchy is conveyed through weight and color, not size. Most labels, titles, and hints across the app are `fontSize.base` or `fontSize.xs`. The distinction between a row's primary line and its secondary line is `foreground` versus `foregroundMuted`.
### Layers
Weight has three tiers, applied by role:
- What belongs in each layer?
- Where are the boundaries?
- What does each layer expose to the layer above?
- **Screen titles** — the title at the top of a screen — use `<ScreenTitle>` (`packages/app/src/components/headers/screen-title.tsx`), which renders `fontSize.base` at weight `400` on compact and `300` on desktop. Top-of-screen titles are lighter on desktop, not heavier. The workspace screen header follows the same rule (`packages/app/src/screens/workspace/workspace-screen.tsx`).
- **Structural labels** use `fontWeight.medium`. This applies to section labels above a stack of rows (`packages/app/src/components/agent-list.tsx:519-523`, `packages/app/src/components/keyboard-shortcuts-dialog.tsx:63-67`), form field labels above an input inside a modal (`packages/app/src/components/add-host-modal.tsx:19-23`, `packages/app/src/components/pair-link-modal.tsx:24-28`), the title at the top of a modal/sheet/dialog (`packages/app/src/components/adaptive-modal-sheet.tsx:90-94`, `packages/app/src/components/ui/combobox.tsx:1607-1611`, `packages/app/src/components/welcome-screen.tsx:48-53`), action button labels in tight components such as the sidebar callout actions (`packages/app/src/components/sidebar-callout.tsx:218-221`), and inline data emphasis on dense metadata rows (`packages/app/src/components/git-diff-pane.tsx:2322-2327`, `packages/app/src/components/file-explorer-pane.tsx:1115-1122`).
- **Content** uses `fontWeight.normal`. This applies to settings rows (`packages/app/src/styles/settings.ts`), sidebar primary list-item titles (`packages/app/src/components/sidebar-workspace-list.tsx:2680-2686`, `packages/app/src/components/agent-list.tsx:572-578`), `<Button>` text (`packages/app/src/components/ui/button.tsx:80-84`), `<StatusBadge>` text (`packages/app/src/components/ui/status-badge.tsx:56-60`), and `<SidebarCallout>` titles (`packages/app/src/components/sidebar-callout.tsx:175-180`).
### Interactions
The rule, condensed: text that _names_ a surface or a group is `medium`. Text that lives _inside_ a surface or a group is `normal`. Top-of-screen titles are `<ScreenTitle>`, which is lighter still.
- How does data flow through the system?
- What triggers what?
- Where do side effects happen?
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.
### Refactoring
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.
- What existing code needs to change?
- Is existing code testable enough? If not, that's part of the plan.
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.
## Create a concrete plan
---
Once the design is clear:
## 4. Buttons
1. **Acceptance criteria** — specific, verifiable outcomes (not "should work well" but "returns X when given Y")
2. **Ordered steps** — what to build first (usually: types, then lowest layer, then up)
3. **What to refactor** before adding new code
4. **How to verify** each step
The button is `<Button>` (`packages/app/src/components/ui/button.tsx`). It has five variants. Each has one job.
## Principles
`default` is the one primary action on a surface — filled with `accent`. At most one per page. The primary slot inside an `<AdaptiveModalSheet>` and the highlighted action on the welcome screen are the canonical uses.
- **Fit, don't force** — new code should fit existing patterns, or refactor first
- **Simple** — the best design is the simplest one that works
- **Verify early** — define how to test before designing the implementation
`secondary` is the paired action when two actions carry equal weight — filled with `surface3`. The component default is `secondary`, which matches its frequency in the codebase.
`outline` is the low-frequency action that lives on a row — transparent with `borderAccent`. Restart, Remove, Update on host detail (`packages/app/src/screens/settings/host-page.tsx:585-594`).
`ghost` is structural and non-committal — no border, no fill. Back arrows, header toggles, "Load more" footers (`packages/app/src/screens/sessions-screen.tsx:54-63`), more-affordances. Ghost is used when the affordance is part of the chrome, not a decision.
`destructive` is filled with `destructive`. It only appears inside a confirm. The button on the page is `outline`; the destructive button is the confirm button inside the dialog.
Sizes: `xs` for ultra-tight inline triggers. `sm` for any button sitting in a row. `md` is the page default. `lg` is reserved for large standalone CTAs.
A `<Pressable>` wrapping a `<Text>` is a sixth variant. It is wrong. `<Button>` accepts `style`, `textStyle`, `leftIcon`, `disabled`, `size`, and `variant`.
---
## 5. Borders
Borders group, separate, or rarely emphasize.
A logical block of related rows lives inside a card — one border around the whole group. The card primitive is `settingsStyles.card`; the keyboard-shortcuts dialog uses the same shape inline (`packages/app/src/components/keyboard-shortcuts-dialog.tsx:68-73`). The border defines what belongs together.
Rows after the first inside a card carry `settingsStyles.rowBorder` — a single top border. The first row never has one. The same divider pattern appears in the keyboard-shortcuts dialog rows (`packages/app/src/components/keyboard-shortcuts-dialog.tsx:74-83`). Rows do not need their own background to feel separated.
A list that is itself the page content — sidebar items in `sidebar-workspace-list.tsx`, the workspace list, the agent list (`packages/app/src/components/agent-list.tsx`) — uses spacing and surface, not borders, to separate items. Rows-in-a-card is an interior pattern; lists-as-pages are not.
Pane chrome — the workspace pane header, the file-explorer header, the diff pane header — uses a single bottom border to separate the header from the content (`packages/app/src/components/git-diff-pane.tsx:2328-2331`). One border, no shadow.
`borderAccent` is reserved for the outline button. Inputs use `border`. Single-thing borders are wrong; a single bordered element is either a card with one row (use the card) or it does not need a border.
---
## 6. Pickers
Five primitives. The pick is determined by option count, the need to search, and how the picker is anchored.
`<DropdownMenu>` is for a small fixed set anchored to a trigger. Theme picker, kebab menus on workspace and project rows (`packages/app/src/components/sidebar-workspace-list.tsx:684-770`), row "more" menus. Items can be async (`status: "pending"`) and can include destructive entries. Under ~10 options where the user knows what they're looking for.
`<Combobox>` is for a large or searchable list. Host switcher in the sidebar footer, model selector in the composer, branch switcher in the workspace header (`packages/app/src/components/branch-switcher.tsx`). The user types to find the option, or the list is long enough to scroll.
`<ContextMenu>` is for right-click and long-press on a target. The row is the trigger; there is no visible affordance. Used for incidental actions on workspace rows in the sidebar (`packages/app/src/components/sidebar-workspace-list.tsx`).
`<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`.
---
## 7. Density and rhythm
Settings detail pages, the projects detail page, and any list+detail content sit inside a centered, max-width 720 column (`packages/app/src/screens/settings-screen.tsx`, `packages/app/src/screens/projects-screen.tsx`). Lines stay readable, the eye does not have to track wide horizontal distances. Form modals carry their own narrower content frame (`packages/app/src/components/add-host-modal.tsx`).
Workspace and chat surfaces use the full width — these are working surfaces, not reading surfaces. The composer carries `MAX_CONTENT_WIDTH` from `packages/app/src/constants/layout.ts` to keep lines readable while letting the workspace pane fill the rest.
Sections sit apart. `<SettingsSection>` owns its own bottom margin; the next thing is wrapped in another `<SettingsSection>`. The agent-list `sectionHeading` carries the same `marginTop`/`marginBottom` rhythm (`packages/app/src/components/agent-list.tsx:511-517`). Adding `marginBottom` to a section is wrong.
Cards inside a section sit closer than sections. Rows inside a card touch — only the divider separates them. The rhythm is page → spacious; section → spacious; card → tight.
Rows have generous vertical padding: roughly 16px of content plus 16px of vertical padding for settings rows, 812px for sidebar list items where many rows must fit. Compressing rows below the established density to fit more on the screen is wrong. Too many rows means more cards or more sections, not smaller rows.
The whitespace is the design.
---
## 8. Responsiveness
Compact-first. The small case is designed; the large case adds chrome around it.
The list+detail pattern is canonical and reused across surfaces. The settings shell (`packages/app/src/screens/settings-screen.tsx`) and the projects screen (`packages/app/src/screens/projects-screen.tsx`) implement it identically:
- On compact: full-screen list with `<BackHeader>` at the top. Tapping a row pushes a full-screen detail with its own `<BackHeader>` that returns to the list.
- On desktop: a 320px sidebar on the left holds the list with `surfaceSidebar` background. The content pane on the right holds the selected detail with `<ScreenHeader>`, `<HeaderIconBadge>`, and `<ScreenTitle>`.
The branching is one `useIsCompactFormFactor()` check at the top of the screen component. The list and the detail are the same components in both layouts; only the framing changes.
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.
A new list+detail feature copies the settings shell. A new workspace-shaped feature copies the workspace shell. Inventing a third shape happens in design review, not in a PR.
---
## 9. Copy and voice
Sentence case. "Pair a device", "Danger zone", "Restart daemon", "Inject Paseo tools", "No sessions yet", "Load more". Proper nouns retain casing — Paseo, Beta, Stable, Local. Title case is wrong.
No trailing periods on row titles, labels, or buttons. No trailing period on a single-clause hint: "What happens when you press Enter while the agent is running" (`packages/app/src/screens/settings-screen.tsx:271-272`). Periods exist inside multi-sentence prose: "Restarts the daemon process. The app will reconnect automatically."
Empty-state strings are short noun phrases or short sentences: "No projects yet", "Select a project", "No sessions yet" (`packages/app/src/screens/sessions-screen.tsx:74-76`), "Host not found".
Buttons are imperative: Save, Cancel, Restart, Remove, Update, Install update, Add host, Load more. In-flight labels are present-participle with a literal three-dot ellipsis: "Saving...", "Restarting...", "Removing...", "Loading...".
Error copy is direct. "Unable to remove host" (`packages/app/src/screens/settings/host-page.tsx:697`), not "Sorry, we couldn't remove the host." Recovery instructions are concrete: "Wait for it to come online before restarting." Errors describe state; they do not editorialize.
Terminology:
- Workspace, never "checkout".
- Host, except where the user-facing concept is the daemon process itself ("Restart daemon").
- Project, not "repo" or "repository".
- Provider, not "model provider".
- Session and agent are distinct: a session is a historical entry in `sessions-screen.tsx`; an agent is a live entity in the workspace.
---
## 10. States
Loading is inline by default. `<LoadingSpinner size={14} color={foregroundMuted} />` sits next to the thing it relates to (`packages/app/src/screens/settings/providers-section.tsx:227-231`). Page-level loading is a centered `<LoadingSpinner size="large">` (`packages/app/src/screens/sessions-screen.tsx:69-72`). Card-level loading is a single short line, not a spinner. In-row dropdown items use `<DropdownMenuItem status="pending" pendingLabel="Removing...">`; the menu item handles its own pending state.
Empty states are short noun phrases. Centered, muted, one or two lines. Sessions screen pairs the empty noun with a single ghost button to navigate back (`packages/app/src/screens/sessions-screen.tsx:74-81`); that pairing is the maximum elaboration. Illustrations and CTAs disguised as empty states are wrong.
Inline errors are a single sentence in `palette.red[300]` `xs`, sitting under the field or inside the card it relates to (`packages/app/src/screens/settings/providers-section.tsx:115-119`).
Page-level alerts — informational notices, success confirmations, warnings, or recoverable errors that need a small visible block on the page — use `<Alert>` (`packages/app/src/components/ui/alert.tsx`). Variants: `default`, `info`, `success`, `warning`, `error`. The chrome is quiet by design: a 1px tinted border, transparent background, a small variant-tinted icon, the title in the variant accent, the description in `foregroundMuted`. Actions go in the `children` slot as `<Button variant="outline" size="sm">` — recovery actions are low-frequency and outline keeps them quiet alongside the alert's accent (`packages/app/src/screens/project-settings-screen.tsx`). One `<Alert>` at a time per region.
Sidebar callouts — cross-cutting alerts that apply across the whole app, like worktree setup, Rosetta install, and desktop update available — register through `useSidebarCallouts()` and render in the left sidebar via `<SidebarCallout>` (`packages/app/src/components/sidebar-callout.tsx`). The chrome (top-border-only, full-width action buttons) is tuned for that ~280px column. Canonical sources: `packages/app/src/components/worktree-setup-callout-source.tsx`, `packages/app/src/desktop/updates/rosetta-callout-source.tsx`, `packages/app/src/desktop/updates/update-callout-source.tsx`. Never import `<SidebarCallout>` into a page — that's what `<Alert>` is for.
Imperative errors are `Alert.alert("Error", "Unable to ...")` (the React Native `Alert` API, not this component) for failures that interrupt the flow and have no place on the page.
Disabled state is `opacity: theme.opacity[50]` on the outer pressable. Color changes for disabled state are wrong; a disabled button is the same button, dimmer.
Partial failure (a list mostly fine but one source errored) is a bordered banner above the list, listing each failure in red-300 `xs` (`packages/app/src/screens/projects-screen.tsx:151-159`). The list still renders.
State surfaces at the smallest scope it affects. Field error stays under the field; page error is a banner; flow-stopping error is an `Alert`.
---
## 11. List rows
The row anatomy is a content column with an optional trailing slot. Inside a card the row is `settingsStyles.row`. Inside a sidebar list the row carries its own padding and `borderRadius.lg` per item (`packages/app/src/components/sidebar-workspace-list.tsx:2614-2625`).
Rows that drill into a detail lead with a chevron in the trailing slot (`ChevronRight`, `iconSize.sm`, `foregroundMuted`). The whole row is the `<Pressable>`. Pair-device row (`packages/app/src/screens/settings/host-page.tsx:644-668`), provider row (`packages/app/src/screens/settings/providers-section.tsx:92-132`), project row in the projects list. Chevron means navigation.
Kebab menus (`<DropdownMenu>` with `<MoreVertical size={14} />` trigger) are for actions on the row, not navigation. Trigger style: `padding: 2`, `borderRadius: 4`, hover background `surface2`. Menu position: `align="end"`. Items use `<DropdownMenuItem leading={<Icon size={14} color={foregroundMuted} />} ...>`. Visibility is `isHovered || isTouchPlatform` — hover-revealed on web, always visible on native (`packages/app/src/components/sidebar-workspace-list.tsx:684-770`).
A row may carry both a chevron and a kebab when both navigation and row-level actions apply. Chevron sits at the end; kebab sits before it.
Switches and segmented controls also sit in the trailing slot. A row that both navigates and toggles is a `<Pressable>` with a `<Switch>` in the trailing slot — the switch calls `event.stopPropagation()` so the row press does not fire (`packages/app/src/screens/settings/providers-section.tsx:92-132`). Sidebar items that hold a status dot, a count, and a kebab follow the same rule (`packages/app/src/components/sidebar-workspace-list.tsx`).
Selected state on rows in a desktop list+detail uses `surfaceSidebarHover` as the background (`packages/app/src/screens/projects-screen.tsx`). Selected state on rows in the sidebar list uses `surface2` (`packages/app/src/components/agent-list.tsx:563-571`).
---
## 12. Status pills and badges
Status pills are `palette.<color>[300]` foreground on a 10%-alpha background of the same color. Success uses green, warning uses amber, danger uses red, muted uses zinc. The `<StatusBadge>` primitive (`packages/app/src/components/ui/status-badge.tsx`) is canonical.
Status dots — the small filled circles next to a host or agent name — are `borderRadius.full` filled with the status color (`statusSuccess`, `statusWarning`, `statusDanger`, or `foregroundMuted`). They sit in the trailing slot of a sidebar row or as a leading marker on a status pill.
The bespoke pills in `packages/app/src/screens/settings/host-page.tsx:97-116`, `packages/app/src/components/agent-list.tsx:607-632`, and `packages/app/src/components/sidebar-workspace-list.tsx:2889-2894` are drift to be removed. New code uses `<StatusBadge>`.
---
## 13. Forbidden
- `fontWeight.medium` on row titles, body text, button labels, badge text, or `<SidebarCallout>` titles. Medium is reserved for the structural-label tier described in §3 — section labels, modal/sheet titles, dense metadata emphasis, and tight action labels. Anything else is `normal`. `<ScreenTitle>` is responsive `400/300` and is never overridden.
- `<Pressable>` wrapping `<Text>` to make a button. `<Button>` exists.
- Bare `<Text>` for a section header inside settings. `<SettingsSection>` exists.
- A "Settings" CTA on a detail page. Detail pages are settings; settings is reached from the sidebar, the host entry, or a row's kebab menu.
- The word "checkout" in UI strings or identifiers. The term is "workspace".
- New color tokens or hardcoded hex outside the palette. Status pill rgba backgrounds are the documented pattern (§12), not a license.
- Placeholder text dimmed beyond `foregroundMuted`. No extra opacity, no italics, no ghost-text.
- `onPointerEnter` and `onPointerLeave`. They do not fire on native iOS. Hover uses Pressable's `onHoverIn`/`onHoverOut` gated with `isHovered || isCompact || isNative`.
- Raw DOM APIs without an `isWeb` guard.
- Spacing values outside the scale. `padding: 20` and `gap: 10` are wrong.
- Color changes for disabled state. Opacity only.
- Destructive actions without `confirmDialog`. Restart, remove, and future destructive actions are confirmed. Worktree archive is confirmed only when git runtime reports uncommitted changes or unpushed commits; clean pushed worktrees archive immediately.
- 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.
---
## 14. Canonical surfaces by pattern
| Pattern | Reference |
| --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| List+detail (compact stack, desktop sidebar+pane) | `packages/app/src/screens/settings-screen.tsx`, `packages/app/src/screens/projects-screen.tsx` |
| Detail card+row | `packages/app/src/screens/settings/host-page.tsx`, `packages/app/src/screens/settings/providers-section.tsx` |
| Section grouping inside a card list | `packages/app/src/screens/settings/settings-section.tsx` |
| Form modal (label + input fields, primary + cancel) | `packages/app/src/components/add-host-modal.tsx`, `packages/app/src/components/pair-link-modal.tsx`, `packages/app/src/components/project-picker-modal.tsx` |
| Destructive confirmation | `confirmDialog` invoked from `packages/app/src/screens/settings/host-page.tsx:541-547` |
| Centered hero / first-run | `packages/app/src/components/welcome-screen.tsx` |
| Sidebar list (workspaces, hosts) | `packages/app/src/components/sidebar-workspace-list.tsx`, `packages/app/src/components/left-sidebar.tsx` |
| Live list of items with sections (agents) | `packages/app/src/components/agent-list.tsx` |
| Historical list (sessions) | `packages/app/src/screens/sessions-screen.tsx` |
| Workspace pane (multi-tab, split) | `packages/app/src/screens/workspace/workspace-screen.tsx` |
| Composer / message input | `packages/app/src/components/composer.tsx`, `packages/app/src/components/message-input.tsx` |
| Pane chrome with single bottom border | `packages/app/src/components/git-diff-pane.tsx`, `packages/app/src/components/file-explorer-pane.tsx`, `packages/app/src/components/terminal-pane.tsx` |
| Page-level alert (info / success / warning / error) | `packages/app/src/components/ui/alert.tsx`, `packages/app/src/screens/project-settings-screen.tsx` |
| Sidebar callout (cross-cutting alert) | `packages/app/src/components/sidebar-callout.tsx`, `packages/app/src/contexts/sidebar-callout-context.tsx`, `packages/app/src/components/worktree-setup-callout-source.tsx`, `packages/app/src/desktop/updates/rosetta-callout-source.tsx`, `packages/app/src/desktop/updates/update-callout-source.tsx` |
| Searchable picker | `packages/app/src/components/ui/combobox.tsx`, `packages/app/src/components/branch-switcher.tsx` |
| Trigger-anchored menu | `packages/app/src/components/ui/dropdown-menu.tsx` (used in `sidebar-workspace-list.tsx`, theme picker) |
| Right-click / long-press menu | `packages/app/src/components/ui/context-menu.tsx` (used in `sidebar-workspace-list.tsx`) |
| Headers (back, screen, menu) | `packages/app/src/components/headers/back-header.tsx`, `screen-header.tsx`, `menu-header.tsx` |

View File

@@ -8,57 +8,192 @@
## Running the dev server
```bash
npm run dev
npm run dev:server
npm run dev:app
npm run dev:desktop
```
The dev script automatically picks an available port. Both the server and Expo app run in a Tmux session — see `CLAUDE.local.md` for system-specific session details.
Root checkout dev is intentionally split across terminals:
### Running alongside the main checkout
- `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`.
Set `PASEO_HOME` to isolate state when running a second instance (e.g., in a worktree):
`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, 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`).
- **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:
```bash
PASEO_HOME=~/.paseo-blue npm run dev
PASEO_HOME=~/.paseo-blue npm run dev # explicit home
PASEO_DEV_SEED_HOME=/path/to/home npm run dev # seed from a different source home
PASEO_DEV_RESET_HOME=1 npm run dev # clear and reseed the derived worktree home
```
- `PASEO_HOME` — path for runtime state (agents, sockets, etc.). Defaults to `~/.paseo`.
- In git worktrees, `npm run dev` derives a stable home like `~/.paseo-<worktree-name>`.
On first run, it seeds that home from `~/.paseo` by copying agent/project JSON metadata
and `config.json`; actual checkout/worktree directories are not copied.
- `PASEO_DEV_SEED_HOME=/path/to/home npm run dev` seeds from a different source home.
- `PASEO_DEV_RESET_HOME=1 npm run dev` clears and reseeds the derived worktree home.
### Daemon endpoints
### Default ports
- Stable daemon launched by the desktop app: `localhost:6767`.
- 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 the main checkout:
In Paseo-managed worktree services, use the injected service environment rather than hardcoded root checkout ports.
- Daemon: `localhost:6767`
- Expo app: `localhost:8081`
### Expo Router layout ownership
In worktrees or with `npm run dev`, ports may differ. Never assume defaults.
Each layout owns only the routes directly inside its directory. In the root
layout, register `h/[serverId]`; do not register host leaf routes such as
`h/[serverId]/workspace/[workspaceId]`, `h/[serverId]/open-project`, or
`h/[serverId]/index` there. The `h/[serverId]/_layout.tsx` file owns those leaf
routes with its own nested stack and relative screen names:
`workspace/[workspaceId]/index`, `open-project`, `index`, and so on. Expo Router
warns with `[Layout children]: No route named ...` when a layout registers
grandchildren. Treat that warning as a route-tree bug: on native, this shape can
leave a nested index route mounted without its local dynamic params and render a
blank screen.
Do not paper over missing required route params by reading global params in the
leaf. Required dynamic params belong to the matched route. If
`useLocalSearchParams()` misses one, fix the layout ownership.
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`, or another non-route directory.
Treat `/h/[serverId]` as the host home route. It resolves to the last remembered
workspace for that host after the workspace-selection store hydrates unless the
host's hydrated workspace list proves that workspace is gone; hosts without a
remembered workspace go to `open-project`.
Keep workspace identity and retention outside native-stack `getId`/
`dangerouslySingular`. Expo Router maps `dangerouslySingular` to React
Navigation `getId`, and `getId` has broken Android native-stack/Fabric by
reordering an already-mounted workspace screen.
### 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`.
The service is designed for concurrent worktrees: it derives a deterministic simulator identity from the worktree path, uses the worktree's assigned `PASEO_PORT`, pins `serve-sim` to that simulator UDID, and only tears down that worktree's helper/simulator state. It must not rely on the globally booted simulator or any fixed Metro port.
Worktree setup best-effort seeds the generated iOS project and newest native build cache from the source checkout before the service runs. The service still validates the native project by running Expo prebuild and Xcode; the seed only avoids paying all setup/build cost from a cold worktree every time.
Starting the service must not create, focus, reveal, or leave behind macOS Simulator.app windows. The browser preview is the user-visible simulator surface.
### Desktop renderer profiling
`npm run dev:desktop` starts Electron with Chromium remote debugging enabled on
`http://127.0.0.1:9223` so renderer CPU profiles can be captured through CDP.
It launches its own Electron-flavored Expo server and passes that URL to Electron.
Override the CDP port with `PASEO_ELECTRON_REMOTE_DEBUGGING_PORT` when `9223` is busy.
### 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
macOS display sleep can leave Chromium's GPU-process display link — the vsync
source that drives frame production — stuck on a stale display. The compositor
then stops producing frames and the window looks frozen: unresponsive to clicks
and keys even though the renderer and every process stay alive. It self-recovers
after a few minutes, which is too long for a foreground app.
`setupDarwinCompositorWatchdog`
(`packages/desktop/src/window/compositor-watchdog/index.ts`) guards against
this. It polls the renderer for frame production every couple of seconds and,
after a sustained stall while the window is visible and unlocked, restarts the
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 trace-level logs.
Check `$PASEO_HOME/daemon.log` for daemon logs. The default level is `info`; set
`PASEO_LOG_LEVEL=trace` before launching the daemon when you need full provider,
session, and agent-manager traces for stuck-state debugging.
### Database queries
Run arbitrary SQL against the SQLite database:
```bash
# Show table row counts
npm run db:query
# Run any SQL
npm run db:query -- "SELECT agent_id, title, last_status FROM agent_snapshots"
npm run db:query -- "SELECT agent_id, seq, item_kind FROM agent_timeline_rows ORDER BY committed_at DESC LIMIT 10"
# Point at a specific DB directory
npm run db:query -- --db /path/to/db "SELECT ..."
```
Auto-detects the running dev daemon's database from `/tmp/paseo-dev.*`, `PASEO_HOME`, or `~/.paseo/db`.
Pass either a DB directory or a `paseo.sqlite` file to `--db`. The script opens the database directly in read-only mode.
The supervisor rotates `daemon.log`. Persisted `log.file.rotate` settings in
`$PASEO_HOME/config.json` win first. Without persisted config, the optional
`PASEO_LOG_ROTATE_SIZE` and `PASEO_LOG_ROTATE_COUNT` env vars override the
defaults. The default rotation is `10m` x `3` files everywhere.
## paseo.json service scripts
@@ -78,12 +213,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:
@@ -99,31 +236,99 @@ Every `scripts` entry with `"type": "service"` receives these environment variab
}
```
## Build sync gotchas
## Bundled daemon web UI
### Relay → Daemon
> 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.
When changing `packages/relay/src/*`, rebuild before running the daemon:
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
npm run build --workspace=@getpaseo/relay
paseo daemon start --web-ui
```
The Node daemon imports `@getpaseo/relay` from `packages/relay/dist/*`, not `src/*`.
### Server → CLI
When changing `packages/server/src/client/*` (especially `daemon-client.ts`) or shared WS protocol types, rebuild before running CLI commands:
Or set the environment variable:
```bash
npm run build --workspace=@getpaseo/server
PASEO_WEB_UI_ENABLED=true paseo daemon start
```
The CLI imports `@getpaseo/server` via package exports resolving to `dist/*`. Stale `dist` means the CLI speaks an old protocol and fails with handshake warnings or timeouts.
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: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:
```bash
npm run build:client # protocol -> client
npm run build:server-deps # highlight -> relay -> protocol -> client
npm run build:server # server-deps -> server -> cli
npm run build:app-deps # highlight -> protocol -> client -> expo-two-way-audio
```
Use `npm run build:server` whenever you have changed any daemon/server-facing package and need clean cross-package types or runtime 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 local CLI (instead of the globally installed `paseo` which points to the main checkout).
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.
```bash
npm run cli -- ls -a -g # List all agents globally
@@ -177,10 +382,24 @@ Get the session ID from the agent JSON (`persistence.sessionId`), then:
## Testing with Playwright MCP
Use Playwright MCP connecting to Metro at `http://localhost:8081` for UI testing.
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.
## App web deploys
`packages/app` exports a single-page Expo web app and deploys the `dist/`
directory to Cloudflare Pages with `npm run deploy:web --workspace=@getpaseo/app`.
PWA install metadata lives in `packages/app/public/manifest.json` and is linked
from `packages/app/public/index.html`. Keep the install icons in `public/` so
Cloudflare serves them from stable root URLs after `expo export`.
Do not add service-worker caching casually. Paseo is a live control surface for
agents, and an aggressive service worker can strand installed users on stale web
code. If offline behavior becomes a product requirement, add it deliberately
with an update strategy and test the installed-app upgrade path.
## Expo troubleshooting
```bash

View File

@@ -15,6 +15,7 @@ This file is auto-generated. Do not edit it by hand.
- `SVG_ICONS` maps icon names (e.g. `"typescript"`) to raw SVG strings
- `EXTENSION_TO_ICON` maps file extensions (e.g. `"ts"`) to icon names
- `getFileIconSvg(fileName)` returns the SVG string for a given filename, falling back to a generic file icon
- `packages/app/src/components/file-explorer-pane.tsx` is the only consumer; it renders the SVG with `SvgXml` from `react-native-svg`
## Adding a new icon

201
docs/floating-panels.md Normal file
View File

@@ -0,0 +1,201 @@
# Floating Panels
Anchored popovers — tooltips, hover cards, dropdowns, autocompletes — that visually
float above an anchor element on iOS, Android, and web. This doc captures the
non-obvious traps. It is **not** a tutorial; it assumes you have seen the
canonical files and are trying to add or change one.
## Canonical files
| File | Use case |
| ---------------------------------------- | ----------------------------------------------------------------- |
| `components/ui/combobox.tsx` | Anchored picker with search; mobile falls back to bottom sheet |
| `components/ui/tooltip.tsx` | Non-interactive hover/long-press tooltip |
| `components/workspace-hover-card.tsx` | Desktop-web hover card with measure + computePosition + Portal |
| `components/ui/autocomplete-popover.tsx` | Slash-command autocomplete anchored to the focused composer input |
Each handles a different mix of concerns: combobox owns input focus, tooltip is
non-interactive, hover-card is web-only desktop, autocomplete keeps the composer
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.
## Gotcha 1 — Android touch hit-test by parent bounds
On Android, a child View whose bounds fall outside its parent's bounds renders
correctly (with `overflow: visible`, the default) but **does not receive touch
events**. `ViewGroup.dispatchTouchEvent` filters touches by the parent's hit
rect first, then iterates children. A touch in the overflowing region never
reaches the parent, let alone the child. iOS and web do not share this rule —
iOS hit-test descends into overflowing children, web uses standard CSS pointer
events. This is the bug that put autocomplete on this path: the popover was
positioned `bottom: 100%` of its parent and worked on iOS/web for months;
Android touches sailed straight through to the chat scroll view behind it.
Two escape hatches in the codebase:
- **`Modal`** (combobox, tooltip on native) — opens a new Android window, so
hit-testing starts fresh in that window. Side effect: a Modal opening on
Android can detach the IME from an underlying TextInput. Fine for combobox
(it has its own input) and tooltip (no input). **Not** fine for autocomplete
(the composer's input must stay focused so the user keeps typing).
- **`<Portal>` from `@gorhom/portal`** (hover-card, autocomplete-popover) —
hoists the React subtree to a fixed mount point whose bounds cover the
screen. Same window, same IME, hit-test works because the new parent is
full-screen. This is the right default when you must keep IME attachment.
Choose the host by layer: app-global overlays use the root host; content
overlays can use the current `FloatingPanelPortalHost` so sliding sidebars
cover them.
Choose Modal vs Portal by whether the underlying input can lose its keyboard.
## Gotcha 2 — Portal breaks lifecycle and coordinate-system inheritance
A Portal escapes Android's hit-test, but it also escapes two things you were
quietly relying on:
- **Lifecycle.** The portal'd subtree mounts at the app root, not inside your
component's natural ancestor chain. When the user navigates away, your
component may stay mounted (offscreen, in a tab) — the popover stays with it.
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.** `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`.
- **Coordinate systems.** `measureInWindow` gives window coordinates. A Portal
renders inside its host, not necessarily at window origin. Position anchored
content relative to the host: `anchorRect - hostRect`. This is what
`measureFloatingPanelPortalHost()` is for.
The fix for transforms is Gotcha 3.
## Gotcha 3 — Reanimated transforms vs `measureInWindow`
`measureInWindow` returns the view's _current_ screen position. In theory that
includes Reanimated-applied transforms (Reanimated updates native view
properties, and Android's `getLocationInWindow` reads transformed coords). In
practice it's racy — the measurement may snapshot mid-animation, and on Android
with Reanimated worklets the result is not always stable.
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 `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 }] }))`
to the popover wrapper.
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. 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
opened.
## Gotcha 4 — Host-relative positioning before platform offsets
The generic anchored-overlay rule is:
1. Measure the anchor with `measureInWindow`.
2. Measure the Portal host with `measureFloatingPanelPortalHost(hostName)`.
3. Position with anchor coordinates relative to the host:
```ts
left = anchorRect.x - hostRect.x;
bottom = hostRect.height - (anchorRect.y - hostRect.y) + offset;
```
Do this before adding any platform offset. If anchor and host are both measured
with `measureInWindow`, Android's status-bar coordinate behavior cancels out.
Only add a status-bar offset when the render surface is not measured in the same
coordinate system. See `tooltip.tsx` for that separate case.
## Gotcha 5 — The two-measurement flash
If your popover needs `top` (or `left`) computed from both:
- the anchor's screen position (`anchorRect` from `measureInWindow`), **and**
- the popover's own size (`contentSize` from `onLayout`),
then a naïve implementation will flash through three positions on every open:
1. **Frame 1** — render with `top: -9999` (or any placeholder) while waiting
for either measurement. Wrapper has no `width`, so the inner content lays
out at its natural (often narrow) intrinsic width.
2. **Frame 2**`anchorRect` lands. Wrapper now has `width: anchorRect.width`.
But the stale `onLayout` from frame 1 has already set `contentSize` to the
narrow-width dimensions. `top = anchorRect.y - wrongHeight - gap` — visible
at the wrong spot.
3. **Frame 3** — real `onLayout` fires with the correct width. `contentSize`
updates. Position snaps to the right place.
The visible jump in frame 2 is the flash. Two pieces solve it, and you need
both:
- **Do not mount the floating content until `anchorRect` is set.** Return
`null` until then. This prevents the bad-width onLayout from happening at
all.
- **Once `anchorRect` is set but `contentSize` isn't, render the wrapper with
the final width but `opacity: 0`.** The first visible paint is at the
correct position. This is the combobox pattern —
`shouldHideDesktopContent` at `combobox.tsx:481, 876`. **Do not** use
`top: -9999` as the placeholder; the layout work still happens at -9999 and
any subsequent state-flash is visible when you flip back.
The "render invisible to measure, then reveal" pattern is the canonical
solution to chicken-and-egg positioning in this codebase. Reach for it before
anything fancier.
## Gotcha 6 — Bottom sheet refs are not lifecycle truth
`@gorhom/bottom-sheet` modals churn their imperative ref while presenting and
dismissing. Do not treat `ref != null` as permission to call `present()`, and do
not treat `ref == null` as the sheet being closed. The user-visible lifecycle is
the desired `visible` prop plus the sheet callbacks (`onChange(-1)`,
`onDismiss`).
If a user closes a sheet with the backdrop or a pan gesture, the sheet may detach
and reattach before React state has acknowledged `visible=false`. Re-presenting
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:
1. **Can the underlying input lose its keyboard?** If yes, use Modal (simpler).
If no, use Portal.
2. **Does the panel need to dismiss on screen change?** Almost always yes —
gate `visible` on an upstream focus prop (`isPaneFocused` or similar).
3. **Is the panel rendered in a Portal host?** Measure the host too. Never use
raw window coordinates as local Portal coordinates.
4. **Does the panel sit above something that moves with the keyboard?** If
yes, slave a Reanimated transform to the same SharedValue (Gotcha 3).
If no, you can probably skip the transform entirely.
5. **Will the panel's content height vary?** If yes, you need both
`anchorRect` and `contentSize` for positioning → apply Gotcha 5 (return
null until anchor, then opacity-0 until contentSize). If no — content has
a known fixed max height — you might be able to use bottom-anchored
positioning (`bottom: windowHeight - anchor.y + gap`) and skip the
`contentSize` round-trip entirely. **But only if the height is genuinely
bounded**. Verify before you commit.
Then copy the closest canonical file and trim.

View File

@@ -2,44 +2,42 @@
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 a project". Code: `ProjectSummary` (`packages/app/src/utils/projects.ts:17`), `projectKey` (`packages/server/src/server/workspace-registry-model.ts:88`). 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/server/src/shared/messages.ts:2128`). 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:9`).
- **Agent** — One AI coding agent run on a daemon (one provider, one model, one cwd, one timeline). UI: "Agent" / "New Agent". Code: `AgentSnapshotPayload` (`packages/server/src/shared/messages.ts:597`). Forbidden: "Task", "Job", "Run".
- **Daemon** — Local Paseo server process; identified by `serverId`. UI: "Daemon" (system contexts only). Code: `serverId` (`packages/server/src/shared/messages.ts:1886`), `DaemonClient` (`packages/server/src/client/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:36`). Forbidden: "Connection" (means `HostConnection`, not host).
- **Placement** — One workspace's relationship to its project (projectKey, projectName, git checkout snapshot). Internal. Code: `ProjectPlacementPayload` (`packages/server/src/shared/messages.ts:2063`).
- **Branch** — Plain git branch. UI: "Switch branch". Code: `currentBranch` (`packages/server/src/shared/messages.ts:2027`); `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/server/src/shared/messages.ts:2042`); CLI `paseo worktree` (`packages/cli/src/commands/worktree/index.ts:8`). Forbidden: "Checkout" as a synonym.
- **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. 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.
- **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 (`deriveWorkspaceKind`, `packages/server/src/server/workspace-registry-model.ts:158`), not stored from a user choice. Code: `PersistedWorkspaceKind` (`packages/server/src/server/workspace-registry-model.ts:8`). 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`).
- **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.
- **Profile** — Internal name for the persisted shape of a host. Code: `HostProfile` (`packages/app/src/types/host-connection.ts:36`). Never user-facing.
- **Provider** — Agent backend (Claude Code, Codex, OpenCode). UI: "Provider". Code: `ProviderSnapshotEntry` (`packages/server/src/shared/messages.ts:193`).
- **Model** — A specific LLM offered by a provider. UI: "Model" / "Select model". Code: `AgentModelDefinition` (`packages/server/src/shared/messages.ts:182`).
- **Terminal** — Workspace-scoped PTY shell streamed over the binary mux channel. UI: "Terminal". Code: `TerminalStreamFrame` (`packages/server/src/shared/terminal-stream-protocol.ts`).
- **Schedule** — Cron-style trigger that creates remote agents. UI: CLI only (`paseo schedule`). Code: `ScheduleCreateRequest` (re-exported from `packages/server/src/shared/messages.ts`). Don't confuse with: Loop (iterative re-execution of one agent).
- **Mode** — Provider-specific operational mode (plan, default, full-access, …). UI: icon-only. Code: `modeId` in `AgentSessionConfig` (`packages/server/src/shared/messages.ts:249`).
- **Attachment** — GitHub PR or Issue bound to an agent prompt. UI: "Attach issue or PR". Code: `AgentAttachment` (`packages/server/src/shared/messages.ts:736`).
- **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:576`); (b) **git merge conflict** (no current UI string).
## Open question — per-host project entry (TBD)
A project aggregates workspaces across daemons. The projects screen and project-settings screen need a name for "one row in the project list per (project, daemon)". `ProjectPlacementPayload` is **per-workspace**, so it isn't this thing — but the noun "placement" could naturally extend (`ProjectDaemonPlacement`, or just "placements grouped by daemon"). The in-progress `ProjectCheckout` type (`packages/app/src/utils/projects.ts:4`) is also per-workspace today, even though the settings UI selector treats it per-daemon (matched on `serverId` alone) — so the code is currently inconsistent with itself.
Candidates: (1) extend "placement" to the (project, daemon) bundle, (2) drop the wrapper type and use `{ host, project, workspaces[] }` plus descriptive UI copy ("project · host X · 2 online"). Recommendation: option (2) — no new noun, use existing terms compositionally. Do not introduce "Checkout" / `ProjectCheckout` as the canonical name.
## Rename before landing (in-progress `Checkout*` references)
- `packages/app/src/utils/projects.ts:4,20,22,23,46,85,104,120,124,126,146,148,155``ProjectCheckout`, `checkouts`, `checkoutCount`, `onlineCheckoutCount`, `buildCheckoutTarget`, `compareCheckouts`.
- `packages/app/src/screens/projects-screen.tsx:73,74,77,90``checkoutLabel`, `${checkoutCount} checkout(s)`, `onlineSuffix`.
- `packages/app/src/screens/project-settings-screen.tsx:17,41,46,55,76,81,110-152,323,443-600``ProjectCheckout` import, `CheckoutSelection`, `CheckoutSelector`, `CheckoutOption`, `usableCheckouts`, `onlineUsableCheckouts`, `selectedCheckout`, `selectedCheckoutKey`, "no online checkout", "no checkout with a valid server", `testID="checkout-selector"`, `testID="checkout-option-*"`, a11y `"Edit X checkout"`.
- `packages/app/src/screens/project-settings-screen.test.tsx:185,195,196,366,369,378,389,412,415,431,432,456,464,478,481,490,501,504,521``checkouts`, `checkoutCount`, `onlineCheckoutCount`, "no checkouts are online", "checkout selector", "online checkout".
- `packages/app/src/utils/projects.test.ts:107,108,111,259``checkoutCount`, `onlineCheckoutCount`, `checkouts`.
(Out of scope for this rename: `ProjectCheckoutLite*Payload` and the git-`checkout` family in `packages/server/src/utils/checkout-*.ts`. Those refer to _git_ checkout state, not the rejected (project, daemon) sense.)
- **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, GitHub PR 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, OMP). 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 new agents. UI: CLI/MCP (`paseo schedule`, `create_schedule`). Don't confuse with: Heartbeat (cron prompt back into the same agent) or Loop (iterative re-execution of one agent).
- **Heartbeat** — Cron-style prompt sent back into the same agent/conversation. MCP: `create_heartbeat`. Use for reminders and babysitting where the status should return inline.
- **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`).
- **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`).
- **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).
## Inconsistencies (documented, not papered over)
- CLI `--host <host>` description `"Daemon host target"` (`packages/cli/src/commands/utils/command-options.ts:5`) blurs daemon/host; the app keeps them distinct.
- `WorkspaceDescriptorPayloadSchema.workspaceKind` accepts legacy `"checkout"` on the wire (`packages/server/src/shared/messages.ts:2137`) while `PersistedWorkspaceKind` does not (`packages/server/src/server/workspace-registry-model.ts:9`).
- In-progress `ProjectCheckout` (`packages/app/src/utils/projects.ts:4`) is per-workspace, but `project-settings-screen.tsx` selects by `serverId` alone — same daemon with multiple workspaces will produce duplicate React keys in the selector.
- CLI `--host <host>` description `"Daemon host target"` (`packages/cli/src/utils/command-options.ts:5`) blurs daemon/host; the app keeps them distinct.
- `WorkspaceDescriptorPayloadSchema.workspaceKind` accepts legacy `"checkout"` on the wire (`packages/protocol/src/messages.ts:2187`) while `PersistedWorkspaceKind` does not (`packages/server/src/server/workspace-registry-model.ts:8`).

138
docs/hover.md Normal file
View File

@@ -0,0 +1,138 @@
# Hover
Read this before writing any hover code. Every hover regression we ship is one of the three failure modes below, and every one of them is solved by the same canonical pattern. The pattern is hardwon — it survived every other shape we tried — so copy it, don't reinvent it.
## The pattern
The canonical implementation lives in `packages/app/src/components/sidebar-workspace-list.tsx`, in the workspace row (around line 1369). When in doubt, open that file and copy the shape.
```tsx
//
// ┌─ Plain View. Tracks hover via pointerenter/pointerleave.
// │
<View
style={styles.workspaceRowContainer}
onPointerEnter={handlePointerEnter}
onPointerLeave={handlePointerLeave}
>
<Pressable // ┐ Separate inner Pressable.
onPress={handlePress} // │ Handles press only.
onPressIn={...} // │ Never has onHoverIn/onHoverOut.
onPressOut={...} // ┘
style={workspaceRowStyle}
>
<View style={styles.workspaceRowMain}>
<View style={styles.workspaceRowLeft}></View>
<WorkspaceRowRightGroup isHovered={isHovered} />
{/* └─ Reveals content based on hover state. */}
</View>
</Pressable>
</View>
```
Five things make this work. Every one of them matters.
1. **Hover lives on a plain `View`, not a `Pressable`.** `Pressable` carries its own internal hover state machine. Nested `Pressable`s fight over it. A plain `View` just dispatches DOM events — no state machine, no fighting.
2. **Press lives on a _separate_ inner `Pressable`.** Hover and press never share an element. The two state machines never see each other.
3. **`onPointerEnter` / `onPointerLeave` are non-bubbling**, mouseenter-style by W3C spec. They fire only when crossing the outer `View`'s bounding box. Crossing into descendants — including descendant `Pressable`s (the kebab menu's buttons, a copy button, a tooltip target) — does **not** fire `pointerleave`. This is why nesting `Pressable`s inside is safe.
4. **The row has a fixed `minHeight`.** When content swaps in on hover (kebab replacing a diff stat), both occupy the same fixed slot. Zero layout shift, zero geometry flicker.
5. **The outer `View` has nothing but `position: relative`.** It exists only to be the hover target. All real layout lives on the inner `Pressable`. The hover-tracker is a sealed envelope around the row; layout changes inside it never leak out and re-enter through the side.
That's the whole pattern. Internalize it.
## When you skip the pattern, here is what breaks
### Failure mode 1 — Nested Pressables fight over hover state
If you put `onHoverIn` / `onHoverOut` on a `Pressable` that has another `Pressable` anywhere inside it (a copy button, an icon button, a nested action), the moment the cursor moves onto the inner `Pressable`, the inner one's hover state machine claims hover and the outer one's `onHoverOut` fires. Your reveal state flips off. The reveal hides. The cursor is no longer over the hidden reveal, so it ends up back over the trigger area. The outer's `onHoverIn` fires. Loop.
This is the most common hover bug shipped in this codebase, by a wide margin. It is what the workspace row is structured to avoid. The fix is not "be clever about handlers" — it's "don't put hover on a Pressable that contains other Pressables."
> **Rule:** the hover-tracking element is a plain `View` with `onPointerEnter` / `onPointerLeave`. Any `Pressable`s — including ones you forgot are Pressables, like `TurnCopyButton`, icon buttons, anything that handles a tap — live inside it.
### Failure mode 2 — The hovered state changes the trigger's geometry
Symptom: you hover a button, it changes appearance, then flickers between hovered and not-hovered without the cursor moving.
Cause: the hover state changed the size or position of the trigger. The cursor was on the original element; the new layout shifts or shrinks it out from under the cursor; `onHoverOut` fires; state reverts; original layout returns; cursor is back over the trigger; `onHoverIn` fires; loop.
Common variants:
- Hover state changes the trigger's `width`, `height`, `padding`, or `borderWidth`.
- Hover state mounts/unmounts a child that pushes the trigger to a new position.
- Hover state swaps the trigger for a different element type, remounting it.
Fixes, in preferred order:
1. **Don't change the trigger's outer geometry on hover.** Change colors, opacity, borders that don't take layout space (`outlineWidth` on web, absolutely positioned overlays), or child content that fits inside the same fixed box. Never change `width`, `height`, `padding`, or `borderWidth` of the hover target itself.
2. **Hide with `opacity` + `pointerEvents`, not conditional rendering**, when the hidden element lives inside the trigger. Mounting/unmounting on hover reflows the layout under the cursor.
3. **Pin the hit area.** Set a fixed `minHeight` / `minWidth` on the trigger so internal swaps (icon-A becomes icon-B on hover) leave the bounding box unchanged. The workspace row's `minHeight: 36` is what makes the kebab/diff-stat swap stable.
### Failure mode 3 — Revealed content lives outside the hover trigger
If hovering element A reveals element B, B must be **inside** A's hover trigger. If B is a sibling, the moment the cursor moves from A toward B it crosses out of A's bounding box, `pointerleave` fires, B disappears.
Wrong:
```tsx
<View>
<View onPointerEnter={...} onPointerLeave={...}> {/* hover trigger */}
<Bubble />
</View>
<TrailingRow /> {/* OUTSIDE — sibling, not child */}
</View>
```
Right:
```tsx
<View onPointerEnter={...} onPointerLeave={...}> {/* hover trigger */}
<Bubble />
<TrailingRow /> {/* INSIDE — child */}
</View>
```
Any gap between A and B (margins between siblings inside the same parent) is part of the parent's bounding box, so the cursor stays inside the hover region while crossing it. No bridge needed.
If A and B genuinely can't share a parent — B portals into a different layer, floats above other content — see [Section: real gaps](#real-gaps-with-floating-panels) below.
## Native fallback
Hover doesn't exist on touch devices. Anything you hide behind hover must have a non-hover path on native and compact layouts:
```tsx
const showControls = isHovered || isNative || isCompact;
```
`isNative` and `isCompact` come from `@/constants/platform` and `@/constants/layout`. Don't use `Platform.OS === "ios"` as a proxy.
`onPointerEnter` / `onPointerLeave` are DOM events. They do not fire on native. You do not need to gate them — on native, hover is unreachable anyway and visibility is driven by `isNative` / `isCompact` in your show-the-controls expression above. This is why the workspace row's pointer events are not wrapped in `if (isWeb)`.
## What about `Pressable.onHoverIn` / `onHoverOut`?
It's fine when a `Pressable` styles **itself** based on its own hover — for example, an icon button that changes color on hover. That's self-contained. The render-prop `<Pressable style={({ hovered }) => ...}>` does the same thing more cleanly and is the preferred form.
It is **not** fine for tracking hover to drive state **outside** that `Pressable` (revealing a sibling, opening a tooltip, showing a kebab) when there is any other `Pressable` inside — because that's Failure Mode 1.
Heuristic: if your hover state is going to be `useState`'d and read by anything other than the same `Pressable`'s own style, do not use `onHoverIn` / `onHoverOut`. Use the canonical pattern.
## Real gaps with floating panels
Sometimes the revealed content can't live inside the trigger — a hover card portals into a different layer, a tooltip floats above other content, a popover renders into a `Portal`. There's a real visual gap the user has to cross with the cursor.
For this case, use `useHoverSafeZone` (`packages/app/src/hooks/use-hover-safe-zone.ts`). It computes a rectangular "bridge" between the trigger and the content; while the pointer is inside trigger, content, or the bridge, the card stays open. A short grace timer absorbs jitter at the edges. The canonical caller is `packages/app/src/components/workspace-hover-card.tsx`.
Don't roll your own. The math is annoying, the edge cases (pointer leaves window, drag in progress, content unmounts) are subtle, and we already paid for the hook.
## Pre-PR checklist
Before opening a PR that touches hover:
- [ ] Hover-tracking is on a plain `View` with `onPointerEnter` / `onPointerLeave`, **not** on a `Pressable` that wraps anything pressable.
- [ ] Any press behavior lives on a separate inner `Pressable` that does not have `onHoverIn` / `onHoverOut`.
- [ ] The hover trigger's bounding box contains every element the user might mouse into while interacting with the feature.
- [ ] Hovered state does **not** change the trigger's outer geometry (`width`, `height`, `padding`, `borderWidth`, mount/unmount of siblings that shift it). Internal swaps fit inside a fixed `minHeight` / `minWidth`.
- [ ] Revealed content inside the trigger uses `opacity` + `pointerEvents`, not conditional rendering, if mounting it would reflow the trigger.
- [ ] Visibility on native and compact layouts works without hover (`isHovered || isNative || isCompact`).
- [ ] If the revealed content sits in a separate layer (portal, floating panel), `useHoverSafeZone` is wired up.
- [ ] You opened the dev server, hovered the trigger, and slowly moved the mouse along **every** revealed element — including any visible gaps — without losing hover state.

82
docs/i18n.md Normal file
View File

@@ -0,0 +1,82 @@
# 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.
## 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.

View File

@@ -143,7 +143,7 @@ Maestro `inputText` fires one character at a time. React Native's **controlled**
For inputs that E2E flows type into (host endpoint, pairing URL, etc.), use an **uncontrolled ref-backed input**: `defaultValue` + `onChangeText` writes into a `useRef`, reads via the ref on submit. No per-keystroke re-render, no dropped characters.
See `add-host-modal.tsx` and `pair-link-modal.tsx` for the pattern. Always pair the source change with a Maestro `assertVisible` on the input's `id + text` after `inputText`, so regressions are caught immediately.
See `pair-link-modal.tsx` for the pattern (`useRef`-backed `onChangeText`, no `value=` prop). Always pair the source change with a Maestro `assertVisible` on the input's `id + text` after `inputText`, so regressions are caught immediately.
### Dropdowns that launch native presenters (iOS)
@@ -249,6 +249,23 @@ const { theme } = useUnistyles();
Regular `View` components can safely use Unistyles dynamic styles — the conflict is specific to `Animated.View`.
## Native Chat Stream Layout
The native agent stream uses an inverted `FlatList`, so chat layout has three coordinate systems:
- chronological stream order
- strategy-ordered array order
- native inverted cell visual order
Do not compute stream neighbors, history/live-head seams, turn footer ownership, assistant block spacing, or tool sequence endings inside React render loops. Those policies live in `packages/app/src/agent-stream/layout.ts` and are unit-tested without React Native rendering.
Platform-specific stream edges belong on `StreamStrategy`:
- forward web uses the last history item as the history/live-head boundary and renders content before a footer
- native inverted uses the first history item as the history/live-head boundary and compensates for inverted cell child order
If a chat footer looks duplicated or appears above the assistant message on mobile, start with `packages/app/src/agent-stream/layout.test.ts`. Do not add a React Native renderer test for this class of bug; make the pure layout invariant fail first.
## iOS Simulator
```bash

View File

@@ -0,0 +1,49 @@
# OpenCode Global Event Verification
Date: 2026-05-11
## Objective
Replace the OpenCode provider's per-directory `/event` stream with OpenCode's `/global/event` stream and remove the EOF polling recovery path that was added for the `/event` regression.
## Environment
- `opencode --version`: `1.14.46`
- `which opencode`: `/Users/moboudra/.asdf/installs/nodejs/22.20.0/bin/opencode`
- `node --version`: `v22.20.0`
- `npm --version`: `10.9.3`
Each OpenCode test file was run independently with:
```bash
/opt/homebrew/bin/timeout 420s npx vitest run <file> --maxWorkers=1
```
## Baseline
Before the provider change, the OpenCode matrix had 16 passing files and 4 failing files:
- `packages/cli/tests/e2e/opencode-invalid-model.test.ts`: Vitest reports "No test suite found in file".
- `packages/server/src/server/agent/providers/opencode-agent.test.ts`: `plan mode blocks edits while build mode can write files` did not observe a completed tool call.
- `packages/server/src/server/daemon-e2e/opencode-initial-prompt-wait.real.e2e.test.ts`: brittle unavailable-model assertion received an auth failure from the upstream API.
- `packages/server/src/server/daemon-e2e/opencode-send-interrupt.real.e2e.test.ts`: timed out waiting for an interrupted sleep tool call, even though the recent bash tool call status was `failed`.
## Post-Change Result
After switching to `/global/event`, removing polling recovery, and replacing the brittle initial-prompt model case with `opencode/big-pickle`, the OpenCode matrix had 18 passing files and 2 baseline-equivalent failing files:
- `packages/cli/tests/e2e/opencode-invalid-model.test.ts`: unchanged; Vitest still reports "No test suite found in file".
- `packages/server/src/server/daemon-e2e/opencode-send-interrupt.real.e2e.test.ts`: unchanged; still times out after the interrupted sleep tool call is already marked `failed`.
The previously failing provider unit file now passes, and `packages/server/src/server/daemon-e2e/opencode-initial-prompt-wait.real.e2e.test.ts` passes with `opencode/big-pickle`.
One live reasoning-dedup matrix run returned no reasoning content; an immediate targeted rerun passed. This appears model-output dependent rather than related to the event-stream change.
## Focused Verification
- `npm run typecheck`
- `npm run lint`
- `git diff --check`
- `npx vitest run packages/server/src/server/agent/providers/opencode-agent.test.ts --maxWorkers=1`
- `npx vitest run packages/server/src/server/agent/providers/opencode-agent.error-handling.real.e2e.test.ts --maxWorkers=1`
- `npx vitest run packages/server/src/server/daemon-e2e/opencode-initial-prompt-wait.real.e2e.test.ts --maxWorkers=1`

View File

@@ -67,10 +67,14 @@ Anyone who builds software:
3. **The daemon as infrastructure.** Server/client architecture enables deployment anywhere.
4. **Open source outlasts funding.** Open source communities are resilient. Contributors become advocates.
## Current state (March 2026)
## Current state (May 2026)
- Desktop (Electron), mobile (iOS/Android), web, CLI
- Providers: Claude Code (Agent SDK), Codex (app-server), OpenCode
- Daily releases
- Community contributions starting (packaging, bug fixes)
- Key UX: split panes, keybinding customization, workspace model
- 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)
- Scheduled agents (cron-style triggers) via app, CLI, and MCP
- Frequent releases (multiple per week)
- Community contributions across packaging, providers, and bug fixes
- Key UX: split panes, keybinding customization, workspace model, in-app browser

View File

@@ -6,15 +6,82 @@ This guide walks through adding a new agent provider end-to-end. There are two i
### ACP (Agent Client Protocol) -- recommended
Extend `ACPAgentClient`. The base class handles process spawning, stdio transport, session lifecycle, streaming, permissions, and model discovery. You provide configuration (command, modes, capabilities) and optionally override `isAvailable()` for auth checks.
Extend `ACPAgentClient` from `packages/server/src/server/agent/providers/acp-agent.ts`. The base class handles process spawning, stdio transport, session lifecycle, streaming, permissions, and model discovery. You provide configuration (command, modes, capabilities) and optionally override `isAvailable()` for auth checks.
Existing ACP providers: `claude-acp`, `copilot`.
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 yourself. This gives full control but requires you to handle process management, streaming, permissions, and session persistence from scratch.
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`, `codex`, `opencode`.
Existing direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`codex-app-server-agent.ts`), `opencode` (`opencode-agent.ts`), `pi` (`providers/pi/agent.ts`), and `omp` (a Pi-compatible built-in backed by the Pi adapter). The dev-only `mock` provider (`mock-load-test-agent.ts`) is also direct.
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 import discovery reads Pi's persisted JSONL session files because Pi RPC does not expose a recent-session listing command. Resume and full history hydration still go through `pi --mode rpc` using the session file as `nativeHandle`.
OMP is a built-in Pi-compatible provider, disabled by default. It uses the `omp` command and imports terminal-started sessions from `~/.omp/agent/sessions` when enabled. Other Pi-compatible forks can still be custom providers that extend `pi`, override `command`, and set `params.sessionDir` to their JSONL session directory.
Pi and OMP currently use different RPC names for slash-command discovery. The Pi package accepts `get_commands`; OMP accepts `get_available_commands`. Keep this as an explicit adapter setting for the built-in provider instead of probing with a fallback, because both packages return unknown-command errors without the request `id`, which otherwise turns a fast mismatch into the normal RPC timeout.
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.
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.
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.
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.
---
## 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.
---
@@ -151,7 +218,7 @@ export const AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [
### 3. Add the factory to the provider registry
In `packages/server/src/server/agent/provider-registry.ts`, import your class and add a factory entry:
In `packages/server/src/server/agent/provider-registry.ts`, import your class and add a factory entry to `PROVIDER_CLIENT_FACTORIES`:
```ts
import { MyProviderACPAgentClient } from "./providers/my-provider-agent.js";
@@ -161,11 +228,13 @@ const PROVIDER_CLIENT_FACTORIES: Record<string, ProviderClientFactory> = {
"my-provider": (logger, runtimeSettings) =>
new MyProviderACPAgentClient({
logger,
runtimeSettings: runtimeSettings?.["my-provider"],
runtimeSettings,
}),
};
```
The factory is invoked with `(logger, runtimeSettings, options)`; `options.workspaceGitService` is also available if you need it (see the `codex` factory for an example). The registry already passes the per-provider runtime settings slice through, so you don't index into the map yourself.
### 4. Add a provider icon (app)
Create `packages/app/src/components/icons/my-provider-icon.tsx` following the pattern from existing icons (e.g., `claude-icon.tsx`):
@@ -187,19 +256,18 @@ export function MyProviderIcon({ size = 16, color = "currentColor" }: MyProvider
}
```
Then register it in `packages/app/src/components/provider-icons.ts`:
Then register it in `packages/app/src/components/provider-icons.ts` by adding an entry to the existing `PROVIDER_ICONS` map (which already covers the built-in providers):
```ts
import { MyProviderIcon } from "@/components/icons/my-provider-icon";
const PROVIDER_ICONS: Record<string, typeof Bot> = {
claude: ClaudeIcon as unknown as typeof Bot,
codex: CodexIcon as unknown as typeof Bot,
// ... existing entries ...
"my-provider": MyProviderIcon as unknown as typeof Bot,
};
```
If no icon is registered, the app falls back to a generic `Bot` icon from lucide.
If no icon is registered, `getProviderIcon()` falls back to a generic `Bot` icon from lucide.
### 5. Add E2E test config
@@ -219,25 +287,25 @@ export const agentConfigs = {
} as const satisfies Record<string, AgentTestConfig>;
```
Add an availability check in `isProviderAvailable()`:
Add an availability check in `isProviderAvailable()`. Note `isCommandAvailable` is async, so all branches `await` it:
```ts
case "my-provider":
return (
isCommandAvailable("my-agent-binary") &&
(await isCommandAvailable("my-agent-binary")) &&
Boolean(process.env.MY_PROVIDER_API_KEY)
);
```
Add to the `allProviders` array:
Add to the `allProviders` array (current built-ins are `claude`, `codex`, `copilot`, `opencode`, `pi`, `omp`):
```ts
export const allProviders: AgentProvider[] = [
"claude",
"claude-acp",
"codex",
"copilot",
"opencode",
"pi",
"my-provider",
];
```
@@ -258,7 +326,9 @@ If your agent does not speak ACP, implement the interfaces from `agent-sdk-types
### Interfaces to implement
**`AgentClient`** -- factory for sessions and model listing:
The interfaces below are abridged signatures — read `agent-sdk-types.ts` for the full source of truth (option bag types, generics, etc.).
**`AgentClient`** -- factory for sessions and model/mode listing:
```ts
interface AgentClient {
@@ -267,16 +337,24 @@ interface AgentClient {
createSession(
config: AgentSessionConfig,
launchContext?: AgentLaunchContext,
options?: AgentCreateSessionOptions,
): Promise<AgentSession>;
resumeSession(
handle: AgentPersistenceHandle,
overrides?: Partial<AgentSessionConfig>,
launchContext?: AgentLaunchContext,
): Promise<AgentSession>;
listModels(options?: ListModelsOptions): Promise<AgentModelDefinition[]>;
fetchCatalog(options: FetchCatalogOptions): Promise<ProviderCatalog>;
isAvailable(): Promise<boolean>;
// Optional:
listPersistedAgents?(options?: ListPersistedAgentsOptions): Promise<PersistedAgentDescriptor[]>;
listImportableSessions(
options?: ListImportableSessionsOptions,
): Promise<ImportableProviderSession[]>;
importSession(
input: ImportProviderSessionInput,
context: ImportProviderSessionContext,
): Promise<ImportedProviderSession>;
getDiagnostic?(): Promise<{ diagnostic: string }>;
}
```
@@ -287,6 +365,7 @@ interface AgentSession {
readonly provider: AgentProvider;
readonly id: string | null;
readonly capabilities: AgentCapabilityFlags;
readonly features?: AgentFeature[];
run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult>;
startTurn(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<{ turnId: string }>;
subscribe(callback: (event: AgentStreamEvent) => void): () => void;
@@ -294,19 +373,28 @@ 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, response: AgentPermissionResponse): Promise<void>;
respondToPermission(
requestId: string,
response: AgentPermissionResponse,
): Promise<AgentPermissionResult | void>;
describePersistence(): AgentPersistenceHandle | null;
interrupt(): Promise<void>;
close(): Promise<void>;
// 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>;
} | null;
}
```
`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

@@ -0,0 +1,158 @@
# Session God-File Decomposition Plan
`packages/server/src/server/session.ts` — 9116 lines, one `Session` class (declared line 724), 128 handlers, ~60 instance fields, 7 entangled domains. Goal: a **strictly behavior-preserving, incremental** decomposition into per-domain controllers, mirroring the existing `TerminalSessionController`.
## Chosen strategy: controller-context (per-domain option-bag controllers)
Each domain becomes a controller class in its own file with the **exact** contract the repo already proved twice (`TerminalSessionController` at `packages/server/src/terminal/terminal-session-controller.ts`, and `CreateAgentLifecycleDispatch`):
- An **options-bag constructor** injecting only what that domain reads.
- An **owned-type `ReadonlySet`** of message types.
- A **NON-async `dispatch(msg): Promise<void> | undefined`** that checks the owned-type set FIRST and returns `undefined` synchronously on a miss (verified at terminal-session-controller.ts:140-143).
- `start()` wired from `subscribeToOptionalManagers`, `dispose()` called by the shell's ordered `cleanup()`.
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).
Rejected alternatives: **feature-module** (free functions + wide context bag) cannot own the live state machines (workspaceUpdatesSubscription, agentUpdatesSubscription, ~25 voice fields) and adds a competing idiom; **mixin-composition** preserves the shared-`this` god object verbatim and requires widening ~325 private fields to protected.
## Slice ordering (least-coupled first)
The task recommended **git/checkout as the first slice — OVERRIDDEN.** Verification: `emitCheckoutStatusUpdate` is called from exactly ONE site (session.ts:4915), inside the workspace-owned `syncWorkspaceGitObserver` callback that ALSO fires workspace effects over shared watch-target maps. Extracting checkout first forces splitting the hardest workspace/git seam before workspace is touched. The strictly safer first cuts are **chat-schedule-loop** (only knot: `handleChatPostRequest`; touches no shared observer/git/voice state) and **provider-catalog** (one shared collaborator + injected predicates).
| # | Slice | Effort | Risk |
| --- | ----------------------------------------------------------------------------- | ------ | ------ |
| 0 | Test net + disjointness tripwire (no extraction) | M | low |
| 1 | ChatScheduleLoopController — **STOP FOR REVIEW after green** | M | low |
| 2 | ProviderCatalogController | M | medium |
| 3 | Split shared workspace-git observer + agent-subscribe fan-out (no controller) | M | high |
| 4 | GitCheckoutController | L | medium |
| 5 | WorkspaceController | XL | high |
| 6 | Voice prereqs: emit() purity + abortController ownership | M | high |
| 7 | VoiceSessionController | XL | high |
| 8a | Agent-lifecycle config setters | M | medium |
| 8b | AgentLifecycleController | XL | high |
---
## Slice 0 — Test net + disjointness tripwire (prerequisite)
No production code moves. Add `session.dispatch-seam.test.ts`. This is the gate the whole plan rests on, because chat/schedule/loop have **zero** handleMessage coverage today (verified).
Write RED-then-GREEN against the **current in-place** Session:
- `chat/post` happy path (asserts `chat/post` response emitted) + fanout-limit error path (asserts the `chat/post` error envelope, NOT a bubbled `rpc_error`).
- one `schedule/*` and one `loop/*` round-trip.
- a handler that throws **synchronously** emits `rpc_error{code:"handler_error"}` + an `activity_log` error frame.
- a handler that **rejects async** emits the SAME pair.
- a table-driven assertion that the union of all controllers' owned-type `ReadonlySet`s is pairwise disjoint and covers the dispatched `SessionInboundMessage` union (grows as controllers land).
**Tests:** `session.dispatch-seam.test.ts`, `session.test.ts`.
---
## Slice 1 — ChatScheduleLoopController ← STOP FOR HUMAN REVIEW after this ships green
**Move:** all 21 handlers (`handleChat*` ×7, `handleSchedule*` ×9, `handleLoop*` ×5), the three rpc-error emitters (`emitChatRpcError`/`emitScheduleRpcError`/`emitLoopRpcError`**kept separate, not merged**), `toScheduleSummary``packages/server/src/server/chat/chat-schedule-loop-controller.ts`. Collapse `dispatchChatScheduleLoopMessage` + `dispatchScheduleMessage` to `return this.chatScheduleLoopController.dispatch(msg)`.
**SessionContext surface:** `emit`, `sessionLogger`, `clientId` (authorAgentId fallback), `chatService`, `scheduleService`, `loopService`, and a narrow agent-control port `{ listAgents, resolveAgentIdentifier, agentStorage.list }` for `handleChatPostRequest` mention fanout.
**Owned-type set MUST include all 7 `chat/*` + 5 `loop/*` + 9 `schedule/*` types** — schedule/\* is currently routed via the chat dispatcher's own `default` arm, so it must stay inside this one controller, or schedule requests silently no-op.
**Behavior note:** least-coupled domain. Move the three rpc-error emitters verbatim (they differ in default code + the `ChatServiceError` branch). **Tests:** `session.dispatch-seam.test.ts`, `loop-service.test.ts`, `session.test.ts`.
---
## Slice 2 — ProviderCatalogController
**Move:** 7 provider handlers + `emitProviderDisabledResponse` + `getProviderSnapshotEntryForRead``packages/server/src/server/provider/provider-catalog-controller.ts`. Move the `providers_snapshot_update` PUSH wiring (1235-1254) into the controller's `start()`/`dispose()`. Collapse `dispatchProviderMessage`.
**SessionContext surface:** `emit`, `sessionLogger`, `providerSnapshotManager` (**shared by reference** — stays a daemon singleton read by checkout/lifecycle/workspace), `isProviderVisibleToClient` (predicate closing over `this`, reads `appVersion` live), `downgradeModeIconsForClient`, `downgradeEntryModesForClient`, agent-control reads `{ listProviderAvailability, listDraftFeatures }`.
**Behavior note:** COMPAT correctness — PUSH and PULL paths MUST call the SAME injected visibility/downgrade closures, reading `appVersion` LIVE (mutated post-construction via `updateAppVersion`). Keep `COMPAT(providersSnapshot)` and `COMPAT(customModeIcons)` comments verbatim. Do NOT pull `resolveStructuredGenerationProviders`/`getFocusedAgentSelectionForCwd` in. **Tests:** `session.dispatch-seam.test.ts`, `daemon-e2e/models.e2e.test.ts`, `session.test.ts`.
---
## Slice 3 — Split the shared observer seams (prerequisite, no controller)
In-place refactor on the shell, two named fan-outs:
1. **workspace-git observer** (4910-4917): make `emitCheckoutStatusUpdate` and `onBranchChanged` injectable callbacks; keep `workspaceGitWatchTargets`/`workspaceGitSubscriptions` shared by reference.
2. **agentManager.subscribe callback** (~1298): refactor into `{ onAgentUpdate, shouldAutoAllowVoicePermission(event), onStreamEvent }`.
**Behavior note:** the single hardest seam, split exactly once before the two domains that co-own it. The observer fires BOTH workspace (`handleWorkspaceGitBranchSnapshot`, `emitWorkspaceUpdateForCwd`) and checkout (`emitCheckoutStatusUpdate`) effects; the agent-subscribe callback is invoked by agent EVENTS (not the `??` chain) and does lifecycle + voice work. Add a test asserting BOTH a `workspace_update` and a `checkout_status_update` fire from one simulated git snapshot change, and a voice-permission test for the auto-allow path. **Tests:** `session.workspace-git-watch.test.ts`, `session.workspaces.test.ts`, `voice-permission-policy.test.ts`, `session.test.ts`.
---
## Slice 4 — GitCheckoutController
**Move:** ~22 `checkout_*`/`stash_*`/PR/github handlers + `handleSubscribeCheckoutDiffRequest`/`handleUnsubscribeCheckoutDiffRequest` + `emitCheckoutStatusUpdate` + `checkoutDiffSubscriptions``packages/server/src/server/checkout/git-checkout-controller.ts`. Collapse `dispatchCheckoutMessage`.
**SessionContext surface:** `emit`, `sessionLogger`, `checkoutDiffManager` (move in + dispose teardown), `github` (shared), `workspaceGitService` (**shared spine**), `workspaceGitWatchTargets`/`workspaceGitSubscriptions` (**shared**), `providerSnapshotManager.listRegisteredProviderIds`. `emitCheckoutStatusUpdate` is now owned here and injected back into the workspace observer seam from Slice 3.
**Behavior note:** safe now that Slice 3 split the observer. `checkoutDiffSubscriptions` teardown moves to `dispose()`, called by `cleanup()` at its current ordinal (8530). **Tests:** `session.dispatch-seam.test.ts`, `checkout-diff-manager.test.ts`, `daemon-e2e/checkout-diff-subscription.e2e.test.ts`, `session.test.ts`.
---
## Slice 5 — WorkspaceController (XL)
**Move:** all workspace handlers (incl. re-homed `handleProjectRenameRequest` and `start_workspace_script_request`) + ~25 private workspace helpers + the whole `workspaceUpdatesSubscription` state machine → `packages/server/src/server/workspace/workspace-controller.ts`.
**SessionContext surface:** `emit`, `sessionLogger`, `projectRegistry`/`workspaceRegistry`/`downloadTokenStore`/script stores/editor cache (**owned**), `workspaceGitService` + watch maps (**shared with checkout**), injected `emitCheckoutStatusUpdate`/`onBranchChanged`, `terminalManager`/`killTerminalsUnderPath`, an `agentUpdatesSubscription` write via a narrow `bufferAgentUpdate` command, `providerSnapshotManager.listRegisteredProviderIds`.
**Behavior note:** the workspaceUpdatesSubscription machine moves WHOLE. The eight already-public workspace methods stay a public surface re-exposed via the shell. Re-homes are atomic remove-from-old-dispatcher + add-to-new-owned-set. **Tests:** `session.workspaces.test.ts`, `session.workspace-git-watch.test.ts`, `session.workspace-resolution-invariants.test.ts`, `session.test.ts`.
---
## Slice 6 — Voice prerequisites (emit purity + abort ownership)
In-place, separately reviewable. Split the `audio_output` TTS-debug branch out of `emit()` (8421-8468, bypasses to `onMessage` at 8454) so `emit` is a pure trace+onMessage sink. Move `convertPCMToWavBuffer` (674-701) to `speech/audio.ts`. Decide abortController ownership.
**Behavior note:** TTS-debug split and abortController ownership are the SAME decision (`ttsDebugStreams.clear()` is tied to `createAbortController` reassignment at 8359). Keep `emit` (with the universal trace) on the shell and inject it everywhere — no trace-less emit. Do NOT inject the AbortController by value. Add: a TTS-debug persistence test (with the debug env flag) before the move, and a barge-in→cleanup regression test asserting the NEW run's signal is aborted. **Tests:** `voice-roundtrip.e2e.test.ts`, `voice-permission-policy.test.ts`, `session.test.ts`.
---
## Slice 7 — VoiceSessionController (XL)
**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).
**Behavior note:** depends on Slices 3 + 6. `cleanup()` stays the ordered orchestrator and calls `voiceController.dispose()` at the position the inlined voice teardown occupies today (8505-8525). **Tests:** `voice-roundtrip.e2e.test.ts`, `voice-local-agent.e2e.test.ts`, `session.voice-mcp-config.test.ts`, `session.test.ts`.
---
## Slice 8a — Agent-lifecycle config setters
Parameterize the 4 setter envelopes `handleSetAgentMode/Model/Feature/Thinking` (4209-4390) into one helper; re-home `handleListCommandsRequest` (misfiled in `dispatchMiscMessage`). Add a handleMessage-driven **failure** test per setter (force the command to reject, assert both the `*_response{accepted:false}` AND the `activity_log` error frame in order) BEFORE collapsing. **Tests:** `session.test.ts`, `session.lifecycle-boundary.test.ts`.
## Slice 8b — AgentLifecycleController (XL, LAST)
**Move:** remaining lifecycle handlers + the `agentUpdatesSubscription` fan-out (`bufferOrEmitAgentUpdate`, `flushBootstrappedAgentUpdates`, `matchesAgentFilter`, `forwardAgentUpdate`) → `packages/server/src/server/agent/agent-lifecycle-controller.ts`. Collapse the three lifecycle dispatchers.
**SessionContext surface:** `emit`, `sessionLogger`, `agentManager`/`agentStorage` (**owned**), injected `forwardAgentUpdate``buildProjectPlacementForCwd` (backed by WorkspaceController), `agentUpdatesSubscription` accessor (owned; workspace writes via `bufferAgentUpdate`), `isProviderVisibleToClient`, `resolveCreateAgentWorkspace`, `supports`, `mcpBaseUrl`, `terminalController.killTerminalForClose`.
**Behavior note:** done LAST — the shared-projection hub. `handleCloseItemsRequest` splits its terminal-kill half from its agent-archive half. **Tests:** `session.test.ts`, `session.wait-for-finish.test.ts`, `session.create-agent-title.test.ts`, `session.lifecycle-boundary.test.ts`, `daemon-client.e2e.test.ts`.
---
## Cross-cutting invariants (every slice)
- **Always** run `npm run typecheck` and `npm run lint` after each slice; run `npm run build:server` before diagnosing cross-package type errors.
- Controller `dispatch` is **NON-async**, guarded by an owned-type `ReadonlySet` check returning `undefined` synchronously on miss. Never `async dispatch`.
- Controllers add **no** try/catch inside `dispatch` — error handling stays in `handleMessage`.
- `cleanup()` stays the single ordered teardown orchestrator on the shell.
- Move domain error emitters **verbatim**; treat any cross-domain emitter merge as a separate, test-guarded change.
- Per-slice typecheck/lint/format via `npm run` scripts; never re-run the full suite locally (run only the listed files with `--bail=1`).

View File

@@ -2,30 +2,57 @@
All workspaces share one version and release together.
## Two steps
A release has exactly two steps. The agent does the first, the user authorizes the second.
**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
- draft the changelog, show it to the user, wait for review
- run the pre-release sanity check, surface findings to the user
- confirm CI is green
**Go-ahead** (user says "go ahead"):
- commit the approved changelog
- run the release
Rules that apply to both steps:
- Last-minute changes always need approval. Every time.
- 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**: you want public test builds first, but you are not ready for the website, npm, or production mobile release flows to move yet.
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)
Before running any stable patch release command:
- Make sure the intended release commit is already committed to `main` and the working tree is clean.
- Make sure local `npm run typecheck` passes on that commit.
- **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.
```bash
npm run release:patch
```
This bumps the version across all workspaces, runs checks, publishes to npm, and pushes the branch + tag (triggering desktop, APK, and EAS mobile workflows).
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.
If asked to "release paseo" without specifying major/minor, treat it as a patch release.
**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).
Use the direct stable path when the current `main` changes are ready to become the public release immediately.
**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".
## Manual step-by-step
@@ -40,28 +67,30 @@ 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 # Bump to X.Y.Z-beta.1, publish npm beta, push commit + tag
# ... 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.
- **Do create a changelog entry for betas.** The beta entry is temporary and gets updated in place until promotion.
- **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:
- smoke a build yourself before promoting it to everyone
- test a build manually in a Linux or Windows VM
- send a build to a user who is hitting a specific problem
- iterate on `beta.1`, `beta.2`, `beta.3`, and so on before deciding to ship broadly
## Staged rollout (stable channel)
Stable desktop releases go out via a linear time-based rollout: 0% admitted when the updater manifests appear, 100% admitted 24 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`.
@@ -74,16 +103,16 @@ Updater clients only discover a release through those `.yml` manifests, so there
### Default behavior
`npm run release:patch` → tag push → 24h ramp. No extra action needed.
`npm run release:patch` → 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 24. To get any other rollout duration on a fresh release, use the post-publish flip below.
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.
### Instant-admit release (rollout_hours=0 from publish)
For a fresh release that should admit everyone immediately (low-risk change, doc-only, hotfix, or just a release you want out fast), cut the release normally and queue the rollout flip immediately after:
```bash
# 1. Cut and publish (default 24h ramp from tag push).
# 1. Cut and publish (default 36h ramp from tag push).
npm run release:patch
# 2. Immediately queue the flip — runs as soon as finalize-rollout completes.
@@ -92,7 +121,7 @@ gh workflow run desktop-rollout.yml \
-f rollout_hours=0
```
**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=24`, 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.
**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.
@@ -132,7 +161,7 @@ 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 24. 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` — 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`).
### Releasing during an active rollout
@@ -145,13 +174,96 @@ If N+1 is a hotfix for a bug in N, dispatch `desktop-rollout.yml -f tag=v0.1.<N+
- **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 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)
iOS and Android store builds are not in `.github/workflows`. They are triggered by the EAS GitHub app the moment the `v*` tag is pushed:
- **Android (Play Store)** — EAS builds with profile `production` and auto-submits to the Play Store via `eas submit` (EAS-managed credentials, no Fastlane).
- **iOS (TestFlight + App Store)** — EAS builds with profile `production`, uploads to TestFlight, and a Fastlane lane submits the build for App Store review.
- **Android APK (GitHub Release asset)** — separate, via `.github/workflows/android-apk-release.yml`. This is the only Android-related workflow that lives in this repo.
There is no `release-mobile.yml` in this repo. Earlier versions of these docs referenced one — that workflow was removed and the EAS GitHub app handles tag triggering directly.
### Watching mobile builds from the terminal
Use the EAS CLI from `packages/app/`:
```bash
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
# 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`). The EAS workflow run's `gitCommitHash` and `trigger` must also match the release tag.
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 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 `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_heartbeat arguments
{
"name": "vX.Y.Z release babysit heartbeat",
"cron": "*/15 * * * *",
"maxRuns": 8, // covers ~2h of build + store-submission window
"prompt": "Heartbeat: check vX.Y.Z release. Run gh run list, eas build:list, eas workflow:runs, and eas workflow:view for the matching Release Mobile run. Report concisely. The release is not done until desktop/APK workflows are green, EAS builds are FINISHED, Android submit_android is SUCCESS, and iOS submit_ios + submit_ios_for_review are SUCCESS. Flag any ERRORED/FAILED/CANCELED/FAILURE loudly.",
}
```
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
The GitHub Release body is populated automatically by the `Release Notes Sync` workflow (`.github/workflows/release-notes-sync.yml`). It triggers on every `v*` tag push and on any push to `main` that touches `CHANGELOG.md`, then runs `scripts/sync-release-notes-from-changelog.mjs` to mirror the matching changelog entry into the release body. You don't need to write release notes on GitHub manually — keep `CHANGELOG.md` correct and the workflow will sync it. To force a re-sync, dispatch the workflow with the tag input.
## Website behavior
- The website download page points to GitHub's latest published **stable** release.
- Published beta prereleases are public on GitHub Releases, but they do **not** become the website download target.
- The website only moves when you publish the final stable release tag like `v0.1.41`.
- The 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
@@ -194,6 +306,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
@@ -205,27 +318,41 @@ Release notes depend on the changelog heading format. The heading **must** be st
## 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` includes stable releases and the current beta line.
- The first beta inserts a top entry like `## 0.1.60-beta.1 - YYYY-MM-DD`.
- The next beta updates that same top entry in place, for example from `0.1.60-beta.1` to `0.1.60-beta.2`.
- Stable promotion updates that same entry in place, for example from `0.1.60-beta.2` to `0.1.60`.
- Do not create duplicate entries for each beta on the same version 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
The changelog is shown on the Paseo homepage. Write it for **end users**, not developers.
- **Frame everything from the user's perspective.** Describe what changed in the app, not what changed in the code. Users care that "workspaces load instantly" — not that a component no longer remounts.
- **Never mention component names, internal modules, or implementation details.** No `WorkingIndicator`, no `accumulatedUsage`, no `reconcileAndEmitWorkspaceUpdates`.
- **Never mention component names, internal modules, or implementation details.** No `WorkingIndicator`, no `accumulatedUsage`, no `reconcileAndEmitWorkspaceUpdates`. Also no "virtualized lists", no "remount", no "memoization", no "debounced", no "fuzzy ranking", no "controlled input", no "uncontrolled input" — these are implementation words masquerading as user-facing copy.
- **Concrete WRONG → RIGHT examples** (real mistakes from past releases):
| Wrong (implementation-facing) | Right (user-facing) |
| ----------------------------------------------------------------------------------- | ----------------------------------------------------------- |
| Switching layouts no longer remounts the active agent | Splitting a pane no longer loses your scroll position |
| Model, mode, and thinking pickers — searchable virtualized lists with fuzzy ranking | Mobile model selector is faster and more straightforward |
| Text inputs in mobile sheets no longer flicker while typing fast | Typing in mobile sheets no longer flickers |
| Compact web sheets no longer crash when swiped to dismiss | Sheets on mobile web no longer crash when swiped to dismiss |
| Reduced re-renders in the agent list | Agent list scrolls smoothly |
| Added debouncing to the search input | Search results no longer lag behind typing |
Test: would a non-developer reader recognise what changed when using the app? If they'd need an engineer to translate ("what's a remount?"), the bullet is still implementation-facing — rewrite it as the symptom the user experiences.
- **Collapse internal iterations.** If a feature was added and then fixed within the same release, just list the feature as working. Users never saw the broken version.
- **Only list changes relative to the previous stable release.** The diff is `v(previous)..HEAD`. If something was introduced and fixed between those two tags, it never shipped — don't mention the fix.
- **Common trap:** when drafting from `git log`, every commit looks like a separate bullet — including the "fix X" commits that landed on top of a brand-new feature in the same release window. Before listing a Fixed entry, check whether the thing being fixed was itself added in this same release. If so, drop the fix and fold it into the feature bullet.
@@ -236,9 +363,12 @@ The changelog is shown on the Paseo homepage. Write it for **end users**, not de
Every bullet must be scannable at a glance. The changelog is not release documentation — it's a list.
- **One sentence per bullet, max.** If a bullet contains two sentences, the second one is doing work that belongs in product docs, not the changelog. Cut it.
- **No trailing periods.** Bullets are list items, not prose. Drop the period at the end of every bullet, including the period inside any bolded lead-in. `**Configurable terminal scrollback**` not `**Configurable terminal scrollback.**`.
- **One line per bullet.** If a bullet wraps to three lines in a narrow column, it's too long.
- **Split bullets that pack multiple distinct changes.** If a bullet uses "and", "plus", a comma list, or an em-dash to chain several independent improvements, break them into separate bullets — even when they share a theme or author. One bullet = one user-facing change.
- **Trim qualifying clauses.** Drop "with a hint shown when…", "matching the CLI's behaviour", "across common install shapes". If the detail doesn't change whether a user cares, cut it.
- **Lead with what the user can do, not the mechanism.** The reader cares about the capability, not how it works under the hood. Do not explain LAN vs WAN, TLS handshakes, IPC, the daemon-relay topology, or any internal concept the user has not asked about. "Self-hosted relays can use a different TLS setting for the public endpoint" — not "Self-hosted relays support a separate TLS setting for the public endpoint, so the daemon can reach the relay over the LAN while the phone reaches it over the public secure address." If a feature genuinely needs background to be understood, it belongs in product docs, with a one-line teaser in the changelog.
- **Lead with the outcome.** "Windows: agents launch reliably from npm `.cmd` shims…" is better than "Windows: agents launch reliably across common install shapes. Claude, Codex, and OpenCode now start correctly…".
- **Attribution follows the split.** When you split a dense bullet, move each PR/author to the bullet it belongs to. Never duplicate the same PR across multiple bullets.
@@ -281,37 +411,50 @@ Entries within each section (Added, Improved, Fixed) are ordered by user impact:
## Pre-release sanity check
Before cutting any release (beta or stable), run a Codex review of the diff as a last line of defence against shipping bugs.
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 always covers **stable-to-HEAD**:
The changelog always covers **previous-stable-to-`HEAD`**, beta and stable alike:
- **Beta release**: the diff and release notes cover `latest stable tag -> HEAD`. The current beta changelog entry is updated in place.
- **Stable release**: the same changelog entry is promoted in place. It still captures the full delta from the previous stable release, not just what changed since the last beta.
- **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.
In other words, betas are checkpoints along the way; the changelog entry remains the single record for the final jump from one stable version to the next.
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`
- [ ] 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
- [ ] `npm run release:beta:patch` (or `: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)
- [ ] 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
- [ ] GitHub `Desktop Release` workflow for the `v*` tag is green
- [ ] GitHub `Android APK Release` workflow for the same tag is green
- [ ] EAS `release-mobile.yml` workflow for the same tag is green
- [ ] 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

84
docs/rpc-namespacing.md Normal file
View File

@@ -0,0 +1,84 @@
# RPC Namespacing
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;
```
The namespace reads left to right:
- Domain: `checkout`
- Provider or subsystem: `github`
- 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`
Use dots, not slashes. Dots are protocol namespaces; slashes imply paths or transport routing.
## Request/Response Pairs
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
```
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.
## Message Shape
Requests keep their parameters at the top level:
```ts
{
type: "checkout.github.set_auto_merge.request",
cwd: "/repo",
enabled: true,
mergeMethod: "squash",
requestId: "req_123"
}
```
Responses put correlated result data under `payload`:
```ts
{
type: "checkout.github.set_auto_merge.response",
payload: {
cwd: "/repo",
enabled: true,
success: true,
error: null,
requestId: "req_123"
}
}
```
Keep `requestId` in both request and response payloads. It is the correlation key.
## Provider Namespacing
Provider-specific behavior belongs under the provider segment:
- `checkout.github.*` for GitHub-specific checkout operations
- `checkout.gitlab.*` for future GitLab-specific checkout operations
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.
## Compatibility
The existing flat RPC names remain part of the protocol until they are intentionally migrated:
```ts
checkout_pr_merge_request;
checkout_pr_merge_response;
```
Do not add new flat names. When migrating old RPCs, keep protocol compatibility rules in mind:
- Add the new names first.
- Gate new feature behavior through `server_info.features.*` when an old host cannot support it.
- Keep old names accepted until the compatibility window expires.
- Mark shims with `COMPAT(...)` and a removal date.

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

@@ -98,6 +98,40 @@ When a test is labeled end-to-end, it calls the real service. No environment var
- Test bodies should read like plain English
- Build a vocabulary of test helpers that make complex flows simple
### File naming
Vitest picks up tests by suffix. The suffix tells the runner which category it belongs to.
| Suffix | What it is | Where it runs |
| --------------------- | ------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
| `*.test.ts(x)` | Unit test — pure, fast, no daemon | `npm run test:unit` |
| `*.posix.test.ts` | Unit test that needs POSIX-only behavior | unit, skipped on Windows |
| `*.browser.test.ts` | App test that needs a real browser (DOM) | `npm run test:browser` (Vitest browser mode, Playwright provider, headless Chromium) |
| `*.e2e.test.ts` | End-to-end against a real daemon | `npm run test:e2e` |
| `*.real.e2e.test.ts` | E2E that hits a real provider (Claude/Codex/Copilot/OpenCode/Pi) — needs creds in `packages/server/.env.test` | `npm run test:integration:real` / `test:e2e:real` |
| `*.local.e2e.test.ts` | E2E that needs a local-only resource | `npm run test:integration:local` / `test:e2e:local` |
App-level Playwright browser E2E lives in `packages/app/e2e/*.spec.ts` and runs via `npm run test:e2e --workspace=@getpaseo/app` (separate from Vitest E2E). App Playwright specs that hit real providers use `*.real.spec.ts` and run through `npm run test:e2e:real --workspace=@getpaseo/app`; the default app E2E project ignores that suffix so CI does not need provider credentials.
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.
### 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.
- App: `packages/app/vitest.setup.ts` provides `expo`/`__DEV__` shims and stubs a few native-only modules (`react-native-unistyles`, `react-native-svg`, `expo-linking`, `@xterm/addon-ligatures`). Stubbing here is for modules that have no meaningful Node behavior — not a license to mock app code.
## Running tests locally
Test suites in this repo are heavy. Running them in bulk freezes the machine, especially with multiple agents in parallel.
- Run only the file you changed: `npx vitest run <path> --bail=1`
- Never run `npm run test` for a whole workspace unless asked.
- For a broad sweep, redirect to a file and read it after: `npx vitest run <path> --bail=1 > /tmp/test-output.txt 2>&1`
- 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`.
## Agent authentication in tests
Agent providers handle their own auth. Do not add auth checks, environment variable gates, or conditional skips to tests. If auth fails, report it.
@@ -124,3 +158,12 @@ If code isn't testable, refactor it. Signs:
- Setup requires too much global state
Aim for deep modules: small interface, deep implementation. Fewer methods = fewer tests needed, simpler params = simpler setup.
## Two test categories, no others
Every test in this repo lives in exactly one of these shapes:
1. **Unit tests with ports and adapters** — production code receives its real-world dependencies (DB, HTTP, CLI process, clock, randomness, filesystem, other modules) through an injected interface. Tests wire a typed in-memory fake colocated with the production module. **No `vi.mock`, `vi.hoisted`, `vi.spyOn` of own exports, JSDOM, `@testing-library` component mounting, RN test renderer, monkey-patched globals, or fake-server fixtures.** If a test needs any of those, the production module is missing a port — fix the seam, then write the test against a fake adapter.
2. **Real end-to-end tests** — real daemon, real network, real browser (Playwright for app code) or a real isolated server instance (for daemon code). No JSDOM, no mocked transport.
Anything in between — component tests in JSDOM, vitest tests that mock the module under test, tests that assert on private state — is slop on its way out.

44
docs/timeline-sync.md Normal file
View File

@@ -0,0 +1,44 @@
# Timeline sync
Agent chat delivery has two paths:
1. **Live stream**`agent_stream` WebSocket messages for immediacy. These may be delta-shaped lifecycle updates.
2. **Authoritative history**`fetch_agent_timeline_request` for correctness. This always returns full projected timeline items, never lifecycle deltas.
The invariant is:
> 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.
## Presence is not delivery
Client heartbeat reports presence:
- device type
- app visibility
- focused agent
- last activity time
Heartbeat is used for notification routing. It must not be used as a correctness gate for `agent_stream` delivery. A stale mobile focus heartbeat may affect whether the user gets notified; it must not make timeline rows disappear from the live stream.
## Catch-up is paged but complete
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`.
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.
## 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.
## Relevant code
- Server live stream forwarding: `packages/server/src/server/session.ts`
- App sync planning: `packages/app/src/timeline/timeline-sync-plan.ts`
- App stream/timeline reducer: `packages/app/src/timeline/session-stream-reducers.ts`
- Session wiring: `packages/app/src/contexts/session-context.tsx`

View File

@@ -4,15 +4,17 @@ This app uses [`react-native-unistyles` v3](https://www.unistyl.es/) for theme-a
That model is powerful, but it has sharp edges. Use this note when adding theme-dependent styles.
## STOP — `useUnistyles()` Is Forbidden
## STOP — `useUnistyles()` Is Banned
**Do not call `useUnistyles()` unless every alternative below has been ruled out and you can explain in a code comment why.** The library authors themselves [strongly advise against it](https://www.unistyl.es/v3/references/use-unistyles):
**Do not call `useUnistyles()`. Anywhere. New code MUST NOT add a call; existing call sites are tolerated only because nobody has rewritten them yet and will be converted as they are touched.** The library authors themselves [strongly advise against it](https://www.unistyl.es/v3/references/use-unistyles):
> We strongly recommend **not using** this hook, as it will re-render your component on every change. This hook was created to simplify the migration process and should only be used when other methods fail.
We have hit this gotcha repeatedly in Paseo. It manifests as periodic, lockstep re-renders of warm subtrees (agent streams, panels, sidebars) even when nothing the user can see has changed — confirmed in profiling: `AgentStreamView` re-rendering constantly with `theme` showing as the only changed input on every render. The hook subscribes the component to **all** Unistyles runtime changes (theme, breakpoint, insets, color scheme, scale) and returns a fresh object reference each call, which also breaks every downstream `useMemo`/`memo` boundary that includes a derived theme value.
We have hit this gotcha repeatedly in Paseo. The hook subscribes the component to **every** Unistyles runtime change (theme, breakpoint, insets, color scheme, scale) and returns a fresh object reference each call. That means a periodic lockstep re-render of warm subtrees (agent streams, panels, sidebars) even when nothing the user can see has changed — confirmed in profiling, with `theme` as the only changed input every cycle. It also breaks every downstream `useMemo`/`memo` boundary that includes a derived theme value.
Before reaching for `useUnistyles()`, work down this list of alternatives in order:
Reviewers MUST reject PRs that introduce a new `useUnistyles()` call. There is no last-resort carveout. If you cannot solve a case with the alternatives below, file an issue and stop — do not paper over it with the hook.
Use these alternatives in order:
### 1. `StyleSheet.create((theme) => ...)` — default
@@ -46,31 +48,9 @@ const ThemedBlur = withUnistyles(BlurView);
(Mind the `> *` child-selector leak documented further down.)
### 4. Lift the read into a tiny leaf component
### 4. There is no "last resort"
If only one prop in a large component needs a theme value at runtime, extract a small leaf component that calls `useUnistyles()` and accept its re-renders in isolation. Never let a whole stream / panel / sidebar / virtualized list subscribe.
### 5. (Last resort) `useUnistyles()`
Only acceptable when both of:
- (a) The value is consumed by a 3rd-party library that cannot be wrapped with `withUnistyles` (per the upstream "When to use it?" list), AND
- (b) The component is small, leaf-level, and not on a hot render path.
If you add a new `useUnistyles()` call, leave a comment on the line explaining which of (a)/(b) applies and why each higher-priority alternative was ruled out.
### Hot-path forbidden list
Do not introduce `useUnistyles()` in or above any of these subtrees — re-renders here are observably expensive:
- `AgentStreamView` and anything it renders (message rows, tool calls, plan card, todo list, activity log, compaction marker, copy buttons)
- `AgentPanel` body / `AgentStreamSection` / `AgentComposerSection`
- `Composer` and `MessageInput`
- `WorkspaceScreen` shell, tabs row, deck wrapper
- `LeftSidebar` row items, `SidebarWorkspaceList`, `CommandCenter`
- Anything inside a virtualized list (`@tanstack/react-virtual`, `FlashList`)
Reviewers must reject PRs that add `useUnistyles()` calls in these areas without a written justification matching the last-resort criteria above.
There is no escape hatch. If none of (1)(3) fit, the problem is upstream — fix it there or file an issue. The hook is not on the table.
## How Updates Propagate
@@ -80,6 +60,36 @@ The important detail: the automatic native path tracks `props.style`. It does no
[`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
Avoid feeding changing pixel values such as `{ top, left }`, `{ maxHeight }`, or `{ minWidth }` into the `style` prop of Unistyles-managed React Native components on web. The web runtime hashes each distinct style object by value and appends a CSS rule to `#unistyles-web`; those rules are not reclaimed during the page lifetime, so pointer-driven positioning can turn into steady stylesheet growth.
Use the inline style escape hatch below for high-churn values. Do not split a component into plain/web/native variants just to keep one measured value out of the CSS registry. Raw DOM wrappers are reserved for real DOM infrastructure, such as terminal hosts, virtualized web rows, or third-party drag wrappers.
## Inline Style Escape Hatch
When a style value is high-churn and must bypass Unistyles' CSS registry, keep the component on the normal Unistyles path and mark only that style object with `inlineUnistylesStyle`.
```tsx
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
const styles = StyleSheet.create({
thumb: {
position: "absolute",
},
});
<View style={[styles.thumb, inlineUnistylesStyle({ height, transform: [{ translateY }] })]} />;
```
This uses Unistyles' own animated-style lane: ordinary styles still become Unistyles classes, while the marked style object stays in React Native's inline style array. Use it for measured geometry, scroll or drag transforms, and pressed/hovered/open state where generating CSS classes is the wrong ownership boundary.
Do not split a component into plain and Unistyles variants just to handle one high-churn value. The component remains a normal Unistyles component; only the specific style object escapes.
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`.
## 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.
@@ -124,18 +134,9 @@ const styles = StyleSheet.create((theme) => ({
This is the pattern used by the settings screen: the screen background lives on a normal `View style={styles.container}`, while the scroll content container only carries layout.
When the content container itself needs themed behavior, wrap the component with [`withUnistyles`](https://www.unistyl.es/v3/references/with-unistyles):
In practice the wrapper-`View` pattern is the one we use. Across the app, `withUnistyles` is now reserved for wrapping leaf components — mostly lucide icons (`ThemedActivityIndicator`, `ThemedChevronDown`, …) and small third-party components like `MarkdownWithStableRenderer` — so they pick up theme-reactive `color`/`tintColor` props without re-rendering their parent.
```tsx
import { ScrollView } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
const ThemedScrollView = withUnistyles(ScrollView);
<ThemedScrollView style={styles.scrollView} contentContainerStyle={styles.contentContainer} />;
```
`withUnistyles` extracts dependency metadata from both `style` and `contentContainerStyle`, subscribes to the relevant theme/runtime changes, and re-renders only that wrapped component when needed. Its [auto-mapping behavior for `style` and `contentContainerStyle`](https://www.unistyl.es/v3/references/with-unistyles#auto-mapping-for-style-and-contentcontainerstyle-props) is the reason it fixes themed `ScrollView` content containers. Reach for it when wrapper-view layout would be awkward or when a third-party component needs theme-aware non-`style` props mapped through Unistyles.
In principle, [`withUnistyles`](https://www.unistyl.es/v3/references/with-unistyles) can also wrap a `ScrollView` to make `contentContainerStyle` theme-reactive via its [auto-mapping behavior for `style` and `contentContainerStyle`](https://www.unistyl.es/v3/references/with-unistyles#auto-mapping-for-style-and-contentcontainerstyle-props). We previously did this on the welcome screen and hit the `> *` child-selector leak documented below; we have since moved the welcome screen to the wrapper-`View` pattern. If you find yourself reaching for `withUnistyles(ScrollView)`, treat it as a smell and check whether a wrapper view works first.
The smallest escape hatch is to use `useUnistyles()` and pass an inline value through React:
@@ -155,7 +156,7 @@ Use this sparingly. It works because React re-renders the prop, but it gives up
The sharp edge: Unistyles hashes styles by value. If `withUnistyles` receives a style whose value is **identical** to a style used elsewhere in the app on a plain `View`, both usages get the same hash — and both CSS rules (the element rule and the `> *` child rule) are emitted under the same class name. The `> *` rule then leaks onto the direct children of every `View` that shares the hash.
Concrete regression we hit: `welcome-screen.tsx` had `const ThemedScrollView = withUnistyles(ScrollView)` with `style={{ flex: 1, backgroundColor: theme.colors.surface0 }}`. `agent-panel.tsx` had `root` and `container` styles with the exact same value. All three collided on class `unistyles_j2k2iilhfz`, so the browser stylesheet contained:
Concrete regression we hit: `welcome-screen.tsx` had `const ThemedScrollView = withUnistyles(ScrollView)` with `style={{ flex: 1, backgroundColor: theme.colors.surface0 }}`. `panels/agent-panel.tsx` had `root` and `container` styles with the exact same value. All three collided on class `unistyles_j2k2iilhfz`, so the browser stylesheet contained:
```css
.unistyles_j2k2iilhfz {
@@ -231,15 +232,49 @@ If a style factory is cheap, skipping `useMemo` entirely is also fine.
Do not import `theme` from `@/styles/theme` for live UI colors. That export is a dark-theme compatibility default, so using it in render code leaves icons, placeholders, or third-party props pinned to dark colors in light mode.
Use `useUnistyles()` inside the component instead:
Wrap the icon (or other leaf component) with `withUnistyles` instead, so only that node re-renders when the theme changes:
```tsx
const { theme } = useUnistyles();
import { ChevronDown } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />;
const ThemedChevronDown = withUnistyles(ChevronDown);
const styles = StyleSheet.create((theme) => ({
icon: { color: theme.colors.foregroundMuted },
}));
<ThemedChevronDown size={theme.iconSize.md} style={styles.icon} />;
```
Importing `baseColors`, theme-name constants, or `type Theme` is fine when the value is intentionally static or type-only.
This is the dominant pattern in the app today (see `sidebar-workspace-list.tsx`, `message.tsx`, the workspace screens). Reserve `useUnistyles()` for the last-resort cases described at the top of this file. Importing `baseColors`, theme-name constants, or `type Theme` is fine when the value is intentionally static or type-only.
## Reanimated `Animated.View` + Dynamic Styles Crashes
Do not apply `StyleSheet.create((theme) => ...)` styles to a Reanimated `Animated.View`. Unistyles wraps styled components in a `<UnistylesComponent>` and patches native view props from C++ via the ShadowRegistry. Reanimated also reaches into the same native node from its worklet runtime. When a theme change fires, both systems try to mutate the same node and the app crashes with `Unable to find node on an unmounted component.` This was a real iOS sidebar crash on theme toggle (commit `4896cfe9`).
Fix: keep static positioning on the `Animated.View` in plain React Native `StyleSheet`, and pass theme-dependent values (e.g. `backgroundColor`) as inline style from `useUnistyles()` — the inline path is acceptable here because no other escape works:
```tsx
import { StyleSheet as RNStyleSheet } from "react-native";
import Animated from "react-native-reanimated";
import { useUnistyles } from "react-native-unistyles";
const positionStyles = RNStyleSheet.create({
sidebar: { position: "absolute", inset: 0, width: 280 },
});
function Sidebar() {
const { theme } = useUnistyles();
return (
<Animated.View
style={[positionStyles.sidebar, animatedStyle, { backgroundColor: theme.colors.surface1 }]}
/>
);
}
```
This is one of the rare places `useUnistyles()` is the right tool: there is no `withUnistyles(Animated.View)` equivalent, the affected component is small, and the alternative is a crash.
## Adaptive Themes And Persisted Settings
@@ -256,6 +291,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`:
@@ -285,7 +335,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
@@ -299,4 +349,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

@@ -26,11 +26,18 @@
let
pkgs = pkgsFor system;
paseo = pkgs.callPackage ./nix/package.nix { };
isLinux = nixpkgs.lib.elem system [
"x86_64-linux"
"aarch64-linux"
];
in
{
default = paseo;
paseo = paseo;
}
// nixpkgs.lib.optionalAttrs isLinux {
desktop = pkgs.callPackage ./nix/desktop-package.nix { inherit paseo; };
}
);
nixosModules.default = self.nixosModules.paseo;

View File

@@ -3,6 +3,9 @@ pre-commit:
jobs:
- name: format
glob: "*.{css,js,json,jsonc,jsx,md,ts,tsx,yaml,yml}"
exclude:
- "package-lock.json"
- "**/package-lock.json"
run: npm run format:check:files -- {staged_files}
- name: lint
glob: "*.{js,jsx,ts,tsx}"

159
nix/desktop-package.nix Normal file
View File

@@ -0,0 +1,159 @@
{
lib,
stdenv,
buildNpmPackage,
nodejs_22,
python3,
makeWrapper,
copyDesktopItems,
makeDesktopItem,
electron,
libuv,
# Reuse the daemon's prebuilt npm-deps FOD. Same lockfile, same content —
# without this, the desktop drv produces a separately-named store path
# (`paseo-desktop-<v>-npm-deps`) and refetches the entire registry. Override
# the upstream hash via `paseo.override { npmDepsHash = "..."; }`.
paseo,
}:
buildNpmPackage rec {
pname = "paseo-desktop";
version = (builtins.fromJSON (builtins.readFile ../package.json)).version;
src = lib.cleanSourceWith {
src = ./..;
filter =
path: type:
let
baseName = builtins.baseNameOf path;
relPath = lib.removePrefix (toString ./..) path;
in
# Exclude mobile-only platform code (we only need the web/electron build)
!(lib.hasPrefix "/packages/app/android" relPath)
&& !(lib.hasPrefix "/packages/app/ios" relPath)
# Website is unrelated to the desktop app
&& !(lib.hasPrefix "/packages/website" relPath)
# Test fixtures and build artifacts
&& !(lib.hasSuffix ".test.ts" baseName)
&& !(lib.hasSuffix ".e2e.test.ts" baseName)
&& baseName != "node_modules"
&& baseName != ".git"
&& baseName != ".paseo"
&& baseName != ".DS_Store"
&& baseName != "release";
};
nodejs = nodejs_22;
inherit (paseo) npmDeps;
# Prevent onnxruntime-node's install script from running during automatic
# npm rebuild. We manually rebuild only node-pty in buildPhase.
npmRebuildFlags = [ "--ignore-scripts" ];
nativeBuildInputs = [
python3 # for node-gyp (node-pty)
makeWrapper
copyDesktopItems
];
buildInputs = lib.optionals stdenv.hostPlatform.isLinux [ libuv ];
dontNpmBuild = true;
env = {
EXPO_NO_TELEMETRY = "1";
# Expo's web build pulls in some pre-bundled assets; ensure it doesn't try
# to phone home during the build.
CI = "1";
};
buildPhase = ''
runHook preBuild
# Native deps (terminal emulation; libuv-linked on Linux)
npm rebuild node-pty
# Server workspaces (highlight + relay + protocol + client + server + cli)
npm run build:server
# App workspace deps not covered by build:server
npm run build --workspace=@getpaseo/expo-two-way-audio
# Expo web export for the Electron renderer
( cd packages/app && PASEO_WEB_PLATFORM=electron npx expo export --platform web )
# Desktop main process (tsc only NOT electron-builder)
npm run build:main --workspace=@getpaseo/desktop
runHook postBuild
'';
installPhase = ''
runHook preInstall
mkdir -p $out/share/paseo-desktop $out/bin
# Preserve the monorepo layout so main.js's dev-mode path resolution
# (`__dirname/../../app/dist`, `__dirname/../assets/icon.png`) works
# without patching: invoked unpackaged via `electron path/to/main.js`,
# `app.isPackaged` is false, so these relative paths are used.
#
# Copy the entire packages/ tree (not just built artifacts) because npm
# creates workspace symlinks from node_modules/@getpaseo/* into packages/*.
# Missing any workspace package leaves dangling symlinks and fails the
# noBrokenSymlinks output check. The cleanSourceWith filter above already
# drops the big platform-specific things (android/ios, website, tests).
cp package.json $out/share/paseo-desktop/
cp -a packages $out/share/paseo-desktop/
cp -a node_modules $out/share/paseo-desktop/
# Skills directory referenced at runtime by some agents
if [ -d skills ]; then
cp -a skills $out/share/paseo-desktop/
fi
# Hicolor icon for desktop environments
install -Dm644 packages/desktop/assets/icon.png \
$out/share/icons/hicolor/512x512/apps/paseo-desktop.png
# Launcher wraps nixpkgs electron.
# --no-sandbox: Chromium's setuid sandbox can't live in /nix/store
# (immutable, no setuid). Acceptable for v1; a follow-up can wire
# `security.wrappers` via a NixOS module for users who want the sandbox.
#
# EXPO_DEV_URL: We run unpackaged via `electron path/to/main.js`, so
# `app.isPackaged` is false. In that mode main.ts loads `DEV_SERVER_URL`
# (defaults to http://localhost:8081 the Expo dev server, which doesn't
# exist here). Point it at the `paseo://` protocol handler instead, which
# serves from `__dirname/../../app/dist` (our install layout matches).
makeWrapper ${electron}/bin/electron $out/bin/paseo-desktop \
--add-flags "$out/share/paseo-desktop/packages/desktop/dist/main.js" \
--add-flags "--no-sandbox" \
--set EXPO_DEV_URL "paseo://app/"
copyDesktopItems
runHook postInstall
'';
desktopItems = [
(makeDesktopItem {
name = "paseo-desktop";
desktopName = "Paseo";
genericName = "AI Coding Agents";
comment = "Self-hosted daemon for AI coding agents";
exec = "paseo-desktop";
icon = "paseo-desktop";
categories = [ "Development" ];
startupWMClass = "Paseo";
})
];
meta = {
description = "Paseo desktop app (Electron wrapper)";
homepage = "https://github.com/getpaseo/paseo";
license = lib.licenses.agpl3Plus;
mainProgram = "paseo-desktop";
platforms = lib.platforms.linux;
};
}

View File

@@ -81,7 +81,59 @@ in
enable = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Whether to enable the relay connection for remote access via app.paseo.sh.";
description = ''
Whether to enable relay-based remote access. When false, the daemon
runs with `--no-relay` and only accepts direct (LAN/loopback)
connections.
'';
};
mode = lib.mkOption {
type = lib.types.enum [ "hosted" "remote" ];
default = "hosted";
description = ''
How the daemon reaches the relay when `relay.enable = true`:
- `"hosted"` (default): use the upstream `app.paseo.sh` relay.
Preserves the current behavior; no extra options needed.
- `"remote"`: connect to a self-hosted relay at
`relay.host:relay.port`. Sets `PASEO_RELAY_ENDPOINT` and
`PASEO_RELAY_USE_TLS` for the daemon.
A `"local"` mode (running a relay on the same host as a systemd
unit) is not yet implemented the relay package currently only
ships a Cloudflare Workers adapter. Tracked separately.
'';
};
host = lib.mkOption {
type = lib.types.str;
default = "";
example = "relay.example.com";
description = "Relay hostname. Required when `relay.mode = \"remote\"`.";
};
port = lib.mkOption {
type = lib.types.port;
default = 443;
description = "Relay port. Used when `relay.mode = \"remote\"`.";
};
useTls = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Whether to use TLS when connecting to the relay. Used when `relay.mode = \"remote\"`.";
};
publicUseTls = lib.mkOption {
type = lib.types.nullOr lib.types.bool;
default = null;
description = ''
Whether the public (client-facing) relay endpoint uses TLS.
When `null` (default), the daemon falls back to `relay.useTls`.
Override when the internal path is plain `ws://` behind a
TLS-terminating reverse proxy.
'';
};
};
@@ -94,8 +146,9 @@ in
When Paseo runs as a real user (not the default system user), AI agents
need access to the user's tools (git, ssh, etc.). This adds the user's
NixOS profile and system paths so agents can use them without manually
setting PATH.
NixOS profile, home-manager profile (`~/.nix-profile/bin` and
`~/.local/state/nix/profile/bin`), and system paths so agents can use
them without manually setting PATH.
Enabled by default when `user` is set to a non-default value.
'';
@@ -111,9 +164,50 @@ in
'';
description = "Extra environment variables for the Paseo daemon.";
};
settings = lib.mkOption {
type = (pkgs.formats.json { }).type;
default = { };
example = lib.literalExpression ''
{
daemon.mcp = { enabled = true; injectIntoAgents = false; };
agents.providers.myAcp = {
extends = "acp";
label = "My Agent";
command = { path = "/run/current-system/sw/bin/my-acp"; };
};
log.file = { level = "info"; path = "/var/lib/paseo/daemon.log"; };
}
'';
description = ''
Declarative content for `$PASEO_HOME/config.json`. Rendered to JSON
and installed on every service start.
Runtime mutations to `config.json` (e.g. via `paseo daemon set-password`
or the mobile app toggling MCP injection / provider overrides) are
overwritten on the next restart. Pick one: manage via this option, or
manage via the CLI not both.
The full schema is defined by `PersistedConfigSchema` in
`packages/server/src/server/persisted-config.ts`.
'';
};
};
config = lib.mkIf cfg.enable {
config = lib.mkIf cfg.enable (
let
settingsFile = (pkgs.formats.json { }).generate "paseo-config.json" cfg.settings;
in
{
assertions = [
{
assertion = !(cfg.relay.enable && cfg.relay.mode == "remote" && cfg.relay.host == "");
message = ''
services.paseo.relay.host must be set when relay.mode = "remote".
'';
}
];
users.users.${cfg.user} = lib.mkIf (cfg.user == "paseo") {
isSystemUser = true;
group = cfg.group;
@@ -131,24 +225,47 @@ in
after = [ "network.target" ];
wantedBy = [ "multi-user.target" ];
preStart = lib.mkIf (cfg.settings != { }) ''
install -m 0600 ${settingsFile} ${cfg.dataDir}/config.json
'';
environment = {
NODE_ENV = "production";
PASEO_HOME = cfg.dataDir;
PASEO_LISTEN = "${cfg.listenAddress}:${toString cfg.port}";
} // lib.optionalAttrs cfg.inheritUserEnvironment {
# mkForce overrides the default PATH from NixOS's systemd module (which
# only includes store paths for coreutils/grep/sed/systemd). Our PATH
# includes /run/current-system/sw/bin which is a superset of those.
PATH = lib.mkForce (lib.concatStringsSep ":" [
"/etc/profiles/per-user/${cfg.user}/bin"
"/run/current-system/sw/bin"
"/run/wrappers/bin"
"/nix/var/nix/profiles/default/bin"
]);
} // lib.optionalAttrs (cfg.hostnames == true) {
} // lib.optionalAttrs cfg.inheritUserEnvironment (
let
# Match dataDir's convention. We can't read users.users.<name>.home
# because the user may be managed outside NixOS.
userHome = "/home/${cfg.user}";
in {
# mkForce overrides the default PATH from NixOS's systemd module (which
# only includes store paths for coreutils/grep/sed/systemd). When the
# daemon runs as a real user, also include home-manager profile paths
# so user-installed CLIs (claude, opencode, codex, ...) are reachable
# by agent processes the daemon spawns.
PATH = lib.mkForce (lib.concatStringsSep ":" (
lib.optionals (cfg.user != "paseo") [
"${userHome}/.nix-profile/bin"
"${userHome}/.local/state/nix/profile/bin"
]
++ [
"/etc/profiles/per-user/${cfg.user}/bin"
"/run/current-system/sw/bin"
"/run/wrappers/bin"
"/nix/var/nix/profiles/default/bin"
]
));
}
) // lib.optionalAttrs (cfg.hostnames == true) {
PASEO_HOSTNAMES = "true";
} // lib.optionalAttrs (lib.isList cfg.hostnames && cfg.hostnames != [ ]) {
PASEO_HOSTNAMES = lib.concatStringsSep "," cfg.hostnames;
} // lib.optionalAttrs (cfg.relay.enable && cfg.relay.mode == "remote") {
PASEO_RELAY_ENDPOINT = "${cfg.relay.host}:${toString cfg.relay.port}";
PASEO_RELAY_USE_TLS = if cfg.relay.useTls then "true" else "false";
} // lib.optionalAttrs (cfg.relay.enable && cfg.relay.mode == "remote" && cfg.relay.publicUseTls != null) {
PASEO_RELAY_PUBLIC_USE_TLS = if cfg.relay.publicUseTls then "true" else "false";
} // cfg.environment;
serviceConfig = {
@@ -172,5 +289,6 @@ in
environment.systemPackages = [ cfg.package ];
networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall [ cfg.port ];
};
}
);
}

1
nix/npm-deps.hash Normal file
View File

@@ -0,0 +1 @@
sha256-7Xru5RdmXKX9U5tv4jn1wXChw3kfBuSnb2oRA+7HR7Q=

View File

@@ -7,6 +7,15 @@
makeWrapper,
# node-pty needs libuv headers on Linux
libuv,
# Exposed so downstream flakes that follow a different nixpkgs revision
# (where `fetchNpmDeps` may produce a different hash for the same lockfile)
# can override via `.override { npmDepsHash = "sha256-..."; }` without
# `overrideAttrs` gymnastics — `npmDepsHash` is destructured from
# `buildNpmPackage`'s args, so `overrideAttrs` cannot reach it.
#
# The default is read from a sidecar file so the CI auto-updater can replace
# the hash with a single file write instead of a sed against this source.
npmDepsHash ? lib.fileContents ./npm-deps.hash,
}:
buildNpmPackage rec {
@@ -40,9 +49,9 @@ buildNpmPackage rec {
nodejs = nodejs_22;
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
npmDepsHash = "sha256-iiqzJ05xnnXyf5eRPqq9xKztwkaxO389vLZFjAh7IjU=";
# Default hash lives in nix/npm-deps.hash (see arg default above).
# CI auto-updates that file when package-lock.json changes (see .github/workflows/).
inherit npmDepsHash;
# Prevent onnxruntime-node's install script from running during automatic
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
@@ -70,8 +79,8 @@ buildNpmPackage rec {
# degrade when unavailable.
npm rebuild node-pty
# Build all daemon packages in dependency order (defined in package.json)
npm run build:daemon
# Build all server packages in dependency order (defined in package.json)
npm run build:server
runHook postBuild
'';
@@ -79,51 +88,29 @@ buildNpmPackage rec {
installPhase = ''
runHook preInstall
# Compute the daemon's runtime closure by static module-graph tracing
# (@vercel/nft from supervisor-entrypoint.js, cli/dist/index.js, and the
# forked terminal-worker-process.js) plus an explicit list of non-JS
# assets read at runtime. The trace script is the single source of
# truth for what the daemon needs at $out auditable in plain JS, no
# npm hoisting / .bin / workspace-symlink footguns.
mkdir -p $out/lib/paseo
node scripts/trace-daemon.mjs > daemon-files.txt
# Copy root package metadata
while IFS= read -r path; do
[ -z "$path" ] && continue
mkdir -p "$out/lib/paseo/$(dirname "$path")"
cp -a "$path" "$out/lib/paseo/$path"
done < daemon-files.txt
# Root package.json lets node resolve the workspace layout when the
# CLI/server bin starts from $out.
cp package.json $out/lib/paseo/
# Copy node_modules (preserving workspace symlinks)
cp -a node_modules $out/lib/paseo/
# Auto-detect which @getpaseo/* packages were built by build:daemon
# (they'll have a dist/ directory). Copy those and remove the rest.
for link in $out/lib/paseo/node_modules/@getpaseo/*; do
name=$(basename "$link")
if [ -d "packages/$name/dist" ]; then
mkdir -p "$out/lib/paseo/packages/$name"
cp "packages/$name/package.json" "$out/lib/paseo/packages/$name/"
cp -a "packages/$name/dist" "$out/lib/paseo/packages/$name/"
if [ -d "packages/$name/node_modules" ]; then
cp -a "packages/$name/node_modules" "$out/lib/paseo/packages/$name/"
fi
else
rm -f "$link"
fi
done
# Copy CLI bin entry
mkdir -p $out/lib/paseo/packages/cli/bin
cp packages/cli/bin/paseo $out/lib/paseo/packages/cli/bin/
# Copy extra server files referenced at runtime
for f in agent-prompt.md .env.example; do
if [ -f packages/server/$f ]; then
cp packages/server/$f $out/lib/paseo/packages/server/
fi
done
# Copy server scripts (including supervisor-entrypoint) needed by CLI
if [ -d packages/server/dist/scripts ]; then
mkdir -p $out/lib/paseo/packages/server/dist/scripts
cp -a packages/server/dist/scripts/* $out/lib/paseo/packages/server/dist/scripts/
fi
# Create wrapper for the server entry point (for systemd / direct use)
mkdir -p $out/bin
makeWrapper ${nodejs}/bin/node $out/bin/paseo-server \
--add-flags "$out/lib/paseo/packages/server/dist/server/server/index.js" \
--add-flags "$out/lib/paseo/packages/server/dist/scripts/supervisor-entrypoint.js" \
--set NODE_ENV production
# Create wrapper for the CLI

11994
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.65",
"version": "0.1.101",
"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"
],
@@ -24,6 +23,8 @@
"workspaces": [
"packages/expo-two-way-audio",
"packages/highlight",
"packages/protocol",
"packages/client",
"packages/server",
"packages/app",
"packages/relay",
@@ -32,18 +33,35 @@
"packages/cli"
],
"scripts": {
"dev": "./scripts/dev.sh",
"dev": "npm run dev:server",
"dev:win": "powershell ./scripts/dev.ps1",
"dev:server": "npm run dev --workspace=@getpaseo/server",
"dev:app": "npm run start --workspace=@getpaseo/app",
"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": "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:daemon": "npm run build --workspace=@getpaseo/highlight && npm run build --workspace=@getpaseo/relay && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli",
"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",
"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": "tsc -p packages/protocol/tsconfig.json --watch --preserveWatchOutput",
"watch:client": "tsc -p packages/client/tsconfig.json --watch --preserveWatchOutput",
"typecheck": "npm run typecheck --workspaces --if-present",
"typecheck:daemon": "npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli",
"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",
"test": "npm run test --workspaces --if-present",
"format": "oxfmt .",
"format:files": "oxfmt",
@@ -52,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",
@@ -61,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:workspace-deps --workspace=@getpaseo/app && 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",
@@ -77,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 build --workspace=@getpaseo/highlight && npm run typecheck --workspace=@getpaseo/relay && npm run build --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && npm run build --workspace=@getpaseo/cli && npm pack --dry-run --workspace=@getpaseo/highlight && npm pack --dry-run --workspace=@getpaseo/relay && 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/server --access public && npm publish --dry-run --workspace=@getpaseo/cli --access public",
"release:publish": "npm publish --workspace=@getpaseo/highlight --access public && npm publish --workspace=@getpaseo/relay --access public && npm publish --workspace=@getpaseo/server --access public && npm publish --workspace=@getpaseo/cli --access public",
"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",
@@ -93,6 +116,7 @@
"devDependencies": {
"@types/ws": "^8.5.14",
"@typescript/native-preview": "7.0.0-dev.20260423.1",
"@vercel/nft": "^1.5.0",
"concurrently": "^9.2.1",
"cross-env": "^10.1.0",
"get-port-cli": "^3.0.0",
@@ -101,6 +125,7 @@
"lefthook": "^2.1.6",
"oxfmt": "0.46.0",
"oxlint": "1.61.0",
"oxlint-tsgolint": "^0.22.1",
"patch-package": "^8.0.1",
"playwright": "^1.56.1",
"typescript": "^5.9.3",
@@ -109,6 +134,8 @@
"overrides": {
"lightningcss": "1.30.1",
"react": "19.1.0",
"react-dom": "19.1.0"
"react-dom": "19.1.0",
"react-native-reanimated": "4.3.1",
"react-native-worklets": "0.8.3"
}
}

View File

@@ -41,13 +41,15 @@ jobs:
submit_ios_for_review:
name: Submit iOS for App Store review
needs: [submit_ios]
needs: [build_ios, submit_ios]
environment: production
runs_on: macos-medium
steps:
- uses: eas/checkout
- name: Install fastlane
working_directory: ./packages/app
run: bundle install
- name: Submit for review
working_directory: ./packages/app
run: bundle exec fastlane ios submit_review
run: |
export APP_VERSION="${{ needs.build_ios.outputs.app_version }}"
export APP_BUILD_VERSION="${{ needs.build_ios.outputs.app_build_version }}"
bundle exec fastlane ios submit_review

View File

@@ -0,0 +1,33 @@
name: Resubmit iOS for App Store review
# Standalone re-trigger for the App Store review submission step. The iOS
# binary is already uploaded to TestFlight via the EAS GitHub app's tag-push
# build; this workflow just runs the fastlane submit_review lane against the
# latest TestFlight build, without rebuilding or re-uploading.
on:
workflow_dispatch:
inputs:
app_version:
type: string
required: false
description: "Marketing version to resubmit, e.g. 0.1.76. Leave empty to target the most recently uploaded iOS build."
app_build_version:
type: string
required: false
description: "CFBundleVersion to resubmit, e.g. 2. Only used when app_version is set."
jobs:
submit_ios_for_review:
name: Submit iOS for App Store review
environment: production
runs_on: macos-medium
steps:
- uses: eas/checkout
- name: Install fastlane
run: bundle install
- name: Submit for review
run: |
export APP_VERSION="${{ inputs.app_version }}"
export APP_BUILD_VERSION="${{ inputs.app_build_version }}"
bundle exec fastlane ios submit_review

View File

@@ -1,3 +1,4 @@
source "https://rubygems.org"
gem "fastlane"
gem "fastlane", "~> 2.234"
gem "multi_json"

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

@@ -0,0 +1,34 @@
// "00-" prefix is intentional: this file must sort before every other spec.
// Sessions history is daemon-global — any agent created by a prior spec hides the empty state.
// If the beforeAll probe below fails, a spec sorted before this file is creating agents.
import { test } from "./fixtures";
import { connectSeedClient } from "./helpers/seed-client";
import { expectSessionsEmptyState, openSessions } from "./helpers/archive-tab";
test.describe("Sessions screen empty state", () => {
test.beforeAll(async () => {
const client = await connectSeedClient();
try {
const history = await client.fetchAgentHistory({ page: { limit: 1 } });
if (history.entries.length > 0) {
throw new Error(
`Sessions empty-state precondition failed: daemon already has ${history.entries.length} agent(s). ` +
`Either a spec that sorts before 00-sessions-empty.spec.ts created agents, ` +
`or the daemon has stale history from a previous run.`,
);
}
} finally {
await client.close().catch(() => undefined);
}
});
test("shows empty placeholder when there is no session history", async ({
page,
withWorkspace,
}) => {
const workspace = await withWorkspace({ prefix: "sessions-empty-" });
await workspace.navigateTo();
await openSessions(page);
await expectSessionsEmptyState(page);
});
});

View File

@@ -0,0 +1,29 @@
import { test } from "./fixtures";
import { gotoAppShell, openSettings } from "./helpers/app";
import { getServerId } from "./helpers/server-id";
import {
expectProviderInstalledInSettings,
installAcpCatalogProvider,
openAddProviderArea,
openSettingsHost,
openSettingsHostSection,
} from "./helpers/settings";
const ACP_PROVIDER = {
id: "hermes",
name: "Hermes",
};
test.describe("ACP provider catalog", () => {
test("adds a catalog provider from settings", async ({ page }) => {
await gotoAppShell(page);
await openSettings(page);
await openSettingsHost(page, getServerId());
// 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,63 @@
import { test } from "./fixtures";
import {
awaitAssistantMessage,
expectAgentIdle,
expectInlineWorkingIndicator,
expectTurnCopyButton,
expectScrollFollowsNewContent,
} from "./helpers/agent-stream";
import { clickNewChat } from "./helpers/launcher";
import { startRunningMockAgent } from "./helpers/composer";
test.describe("Agent stream UI", () => {
test("auto-scroll sticks to bottom across token bursts", async ({ page }) => {
test.setTimeout(120_000);
const agent = await startRunningMockAgent(page, {
prefix: "stream-scroll-",
model: "one-minute-stream",
prompt: "Stream for auto-scroll test.",
});
try {
await awaitAssistantMessage(page);
await expectScrollFollowsNewContent(page);
} finally {
await agent.cleanup();
}
});
test("working-indicator transitions to copy-button when stream ends", async ({ page }) => {
test.setTimeout(60_000);
const agent = await startRunningMockAgent(page, {
prefix: "stream-indicator-",
model: "ten-second-stream",
prompt: "Stream briefly for indicator transition test.",
});
try {
await awaitAssistantMessage(page);
await expectInlineWorkingIndicator(page);
await expectAgentIdle(page, 30_000);
await expectTurnCopyButton(page);
} finally {
await agent.cleanup();
}
});
test("shows elapsed timer on first app-created running turn", async ({ page, withWorkspace }) => {
test.setTimeout(90_000);
const workspace = await withWorkspace({ prefix: "stream-first-app-turn-timer-" });
await workspace.navigateTo();
await clickNewChat(page);
await page.getByText("Model defaults are still loading").waitFor({
state: "hidden",
timeout: 30_000,
});
const prompt = "Stream briefly for first app-created turn timer 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" });
await awaitAssistantMessage(page);
await expectInlineWorkingIndicator(page);
await page.getByTestId("turn-working-elapsed").waitFor({ state: "visible", timeout: 5_000 });
});
});

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,21 @@
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,
connectArchiveTabDaemonClient,
createIdleAgent,
expectArchivedAgentFocused,
expectSessionRowArchived,
expectSessionRowVisible,
expectWorkspaceArchiveOutcome,
expectWorkspaceTabHidden,
fetchAgentArchivedAt,
expectWorkspaceTabVisible,
openSessions,
openWorkspaceWithAgents,
primeAdditionalPage,
@@ -21,17 +24,28 @@ import {
} from "./helpers/archive-tab";
test.describe("Archive tab reconciliation", () => {
let client: Awaited<ReturnType<typeof connectArchiveTabDaemonClient>>;
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 connectArchiveTabDaemonClient();
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 +53,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 +84,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 +94,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 +112,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 +123,33 @@ test.describe("Archive tab reconciliation", () => {
}
});
test("clicking an archived session reopens its closed tab focused", async ({ page }) => {
test("clicking an archived session unarchives it and opens the agent", 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);
await openSessions(page);
await expectSessionRowArchived(page, archived.title);
await clickSessionRow(page, archived.title);
await expectArchivedAgentFocused(page, archived.id);
await expect
.poll(() => fetchAgentArchivedAt(client, archived.id), { timeout: 30_000 })
.toBeNull();
await expect(page).toHaveURL(buildHostWorkspaceRoute(getServerId(), archived.workspaceId), {
timeout: 30_000,
});
await expectWorkspaceTabVisible(page, archived.id);
});
});

View File

@@ -0,0 +1,126 @@
import { expect, test, type Page } from "./fixtures";
import { expectComposerVisible } from "./helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
const MOBILE_VIEWPORT = { width: 390, height: 844 };
async function openMockAgentAtMobileBreakpoint(page: Page) {
await page.setViewportSize(MOBILE_VIEWPORT);
const session = await seedMockAgentWorkspace({
repoPrefix: "bottom-sheet-reopen-",
title: "Bottom sheet reopen e2e",
initialPrompt: "Prepare a bottom sheet reopen test agent.",
});
await openAgentRoute(page, session);
await expect(page.getByTestId("workspace-tab-switcher-trigger")).toBeVisible({
timeout: 30_000,
});
await expectComposerVisible(page);
await expect(page.getByRole("button", { name: /Select model/ })).toBeVisible({
timeout: 30_000,
});
return session;
}
async function withMobileMockAgent(page: Page, run: () => Promise<void>) {
const session = await openMockAgentAtMobileBreakpoint(page);
try {
await run();
} finally {
await session.cleanup();
}
}
function bottomSheetBackdrop(page: Page) {
return page.getByRole("button", { name: "Bottom sheet backdrop" }).first();
}
function bottomSheetHandle(page: Page) {
return page.getByRole("slider", { name: "Bottom sheet handle" }).first();
}
async function expectBottomSheetOpen(page: Page) {
await expect(bottomSheetBackdrop(page)).toBeVisible({ timeout: 10_000 });
}
async function closeBottomSheetWithBackdrop(page: Page) {
const backdrop = bottomSheetBackdrop(page);
const handle = bottomSheetHandle(page);
// Tapping the backdrop is the close path under test, but on a loaded CI runner
// the model-selector sheet re-renders as its model list settles and Gorhom
// drops backdrop presses during that churn — so a tap (even retried) can fail
// to dismiss. Tap the backdrop first; if it survives, drag the handle down,
// which drives Gorhom's pan-to-close directly and is unaffected by the churn.
// The post-close guard below still protects the regression this test exists
// for: a sheet that dismisses, then re-presents.
await expect(async () => {
if (!(await backdrop.isVisible())) {
return;
}
const box = await backdrop.boundingBox();
if (box) {
await page.mouse.click(box.x + box.width / 2, box.y + 24);
}
await page.waitForTimeout(150);
if (await backdrop.isVisible()) {
const handleBox = await handle.boundingBox();
if (handleBox) {
const startX = handleBox.x + handleBox.width / 2;
const startY = handleBox.y + handleBox.height / 2;
await page.mouse.move(startX, startY);
await page.mouse.down();
await page.mouse.move(startX, startY + 400, { steps: 8 });
await page.mouse.up();
}
}
await expect(backdrop).not.toBeVisible({ timeout: 1_000 });
}).toPass({ timeout: 15_000 });
// Guard against the regression where the sheet starts dismissing, then re-presents.
await page.waitForTimeout(500);
await expect(backdrop).not.toBeVisible({ timeout: 1_000 });
}
async function openTabSwitcher(page: Page) {
const trigger = page.getByRole("button", { name: /Switch tabs/ });
await trigger.click();
await expectBottomSheetOpen(page);
}
async function openModelSelector(page: Page) {
await page.getByRole("button", { name: /Select model/ }).click();
await expectBottomSheetOpen(page);
await expect(
page.getByLabel("Bottom Sheet", { exact: true }).getByText("Ten second stream", {
exact: true,
}),
).toBeVisible({ timeout: 10_000 });
}
async function openAndCloseTabSwitcherTwice(page: Page) {
await openTabSwitcher(page);
await closeBottomSheetWithBackdrop(page);
await openTabSwitcher(page);
await closeBottomSheetWithBackdrop(page);
}
async function openAndCloseModelSelectorTwice(page: Page) {
await openModelSelector(page);
await closeBottomSheetWithBackdrop(page);
await openModelSelector(page);
await closeBottomSheetWithBackdrop(page);
}
test.describe("mobile bottom sheet reopen", () => {
test("tab switcher can open, close, reopen, and close again", async ({ page }) => {
await withMobileMockAgent(page, async () => {
await openAndCloseTabSwitcherTwice(page);
});
});
test("model selector can open, close, reopen, and close again", async ({ page }) => {
await withMobileMockAgent(page, async () => {
await openAndCloseModelSelectorTwice(page);
});
});
});

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,148 @@
import { expect, test, type Page } from "./fixtures";
import { composerLocator, expectComposerVisible, submitMessage } from "./helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
import {
expectSessionRowArchived,
expectWorkspaceTabHidden,
expectWorkspaceTabVisible,
openSessions,
} from "./helpers/archive-tab";
interface SlashCommandScenario {
agentId: string;
title: string;
}
const REPLACEMENT_PROMPT = "Replacement prompt after slash clear.";
async function withOpenReadyMockAgent(
page: Page,
input: {
title: string;
model?: string;
modeId?: string;
},
run: (scenario: SlashCommandScenario) => Promise<void>,
): Promise<void> {
const session = await seedMockAgentWorkspace({
repoPrefix: "client-slash-command-",
title: input.title,
model: input.model,
modeId: input.modeId,
initialPrompt: "Prepare a client slash command test agent.",
});
try {
await openAgentRoute(page, session);
await expectWorkspaceTabVisible(page, session.agentId);
await expectComposerVisible(page);
await run({ agentId: session.agentId, title: input.title });
} finally {
await session.cleanup();
}
}
async function runClientSlashCommand(page: Page, command: "/quit" | "/clear"): Promise<void> {
const input = composerLocator(page);
await expect(input).toBeEditable({ timeout: 30_000 });
await input.fill(command);
await expect(input).toHaveValue(command);
await input.press("Enter");
}
async function selectClientSlashCommand(page: Page, query: string, label: string): Promise<void> {
const input = composerLocator(page);
await expect(input).toBeEditable({ timeout: 30_000 });
await input.fill(query);
await expect(page.getByText(label, { exact: true }).first()).toBeVisible({ timeout: 30_000 });
await input.press("Enter");
}
async function expectAgentArchivedInSessions(page: Page, title: string): Promise<void> {
await openSessions(page);
await expectSessionRowArchived(page, title);
}
async function expectReplacementDraftMatchesPreviousSetup(page: Page): Promise<void> {
await expectComposerVisible(page);
await expect(
page.getByRole("button", { name: "Select model (Ten second stream)" }),
).toBeVisible();
await expect(page.getByRole("button", { name: "Select agent mode (Load test)" })).toBeVisible();
}
async function createAgentFromReplacementDraft(page: Page): Promise<void> {
await submitMessage(page, REPLACEMENT_PROMPT);
}
async function waitForReplacementAgentId(page: Page, oldAgentId: string): Promise<string> {
let newAgentId: string | null = null;
await expect
.poll(
async () => {
const ids = await page
.locator('[data-testid^="workspace-tab-agent_"]')
.evaluateAll((nodes) =>
nodes.flatMap((node) => {
if (!(node instanceof HTMLElement)) {
return [];
}
const testId = node.getAttribute("data-testid") ?? "";
if (!testId.startsWith("workspace-tab-agent_")) {
return [];
}
if (node.offsetParent === null) {
return [];
}
return [testId.slice("workspace-tab-agent_".length)];
}),
);
newAgentId = ids.find((id) => id !== oldAgentId) ?? null;
return newAgentId;
},
{ timeout: 30_000 },
)
.not.toBeNull();
if (!newAgentId) {
throw new Error("Replacement agent was not created.");
}
return newAgentId;
}
test.describe("Client slash commands", () => {
test("slash quit archives the active agent and removes its tab", async ({ page }) => {
await withOpenReadyMockAgent(page, { title: "Slash quit e2e" }, async ({ agentId, title }) => {
await runClientSlashCommand(page, "/quit");
await expectWorkspaceTabHidden(page, agentId);
await expectAgentArchivedInSessions(page, title);
});
});
test("slash quit selected from autocomplete archives immediately", async ({ page }) => {
await withOpenReadyMockAgent(
page,
{ title: "Slash quit autocomplete e2e" },
async ({ agentId, title }) => {
await selectClientSlashCommand(page, "/qu", "/exit");
await expectWorkspaceTabHidden(page, agentId);
await expectAgentArchivedInSessions(page, title);
},
);
});
test("slash clear replaces the active agent with a matching draft", async ({ page }) => {
await withOpenReadyMockAgent(
page,
{ title: "Slash clear e2e", model: "ten-second-stream", modeId: "load-test" },
async ({ agentId, title }) => {
await runClientSlashCommand(page, "/clear");
await expectWorkspaceTabHidden(page, agentId);
await expectReplacementDraftMatchesPreviousSetup(page);
await createAgentFromReplacementDraft(page);
await waitForReplacementAgentId(page, agentId);
await expectAgentArchivedInSessions(page, title);
},
);
});
});

View File

@@ -1,16 +1,6 @@
import { expect, test } from "./fixtures";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import { allowPermission, waitForPermissionPrompt } from "./helpers/app";
import { connectTerminalClient } from "./helpers/terminal-perf";
import { createTempGitRepo } from "./helpers/workspace";
function getServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
return serverId;
}
import { allowPermission, waitForPermissionPrompt } from "./helpers/permissions";
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
test.describe("Codex plan approval", () => {
test("shows a single actionable plan panel and removes it after implementation starts", async ({
@@ -18,29 +8,14 @@ test.describe("Codex plan approval", () => {
}) => {
test.setTimeout(180_000);
const repo = await createTempGitRepo("codex-plan-approval-");
const client = await connectTerminalClient();
const session = await seedMockAgentWorkspace({
repoPrefix: "codex-plan-approval-",
title: "Codex plan approval e2e",
initialPrompt: "Emit synthetic plan approval.",
});
try {
const workspaceResult = await client.openProject(repo.path);
if (!workspaceResult.workspace) {
throw new Error(workspaceResult.error ?? `Failed to open project ${repo.path}`);
}
const agent = await client.createAgent({
provider: "mock",
cwd: repo.path,
title: "Codex plan approval e2e",
modeId: "load-test",
model: "ten-second-stream",
initialPrompt: "Emit synthetic plan approval.",
});
const agentUrl = `${buildHostWorkspaceRoute(
getServerId(),
repo.path,
)}?open=${encodeURIComponent(`agent:${agent.id}`)}`;
await page.goto(agentUrl);
await openAgentRoute(page, session);
await waitForPermissionPrompt(page, 120_000);
@@ -54,8 +29,7 @@ test.describe("Codex plan approval", () => {
});
await expect(page.getByTestId("timeline-plan-card")).toHaveCount(0);
} finally {
await client.close();
await repo.cleanup();
await session.cleanup();
}
});
});

View File

@@ -0,0 +1,273 @@
import { expect, test } from "./fixtures";
import { clickNewChat } from "./helpers/launcher";
import { expectComposerVisible } from "./helpers/composer";
import { expectAgentIdle } from "./helpers/agent-stream";
import {
openAttachmentMenu,
openGithubPickerFromMenu,
attachImageFromMenu,
expectAttachmentPill,
removeAttachmentPill,
openImageLightbox,
closeImageLightbox,
pressInterruptShortcut,
expectComposerDraft,
expectComposerDisabled,
expectComposerEditable,
expectAttachButtonDisabled,
fillComposerDraft,
sendDraftToQueue,
expectQueuedMessageButton,
startRunningMockAgent,
selectGithubOption,
expectGithubAttachmentPill,
openGithubWorkspace,
} from "./helpers/composer";
import { delayBrowserAgentCreatedStatus, openNewWorkspaceComposer } from "./helpers/new-workspace";
import { gotoAppShell } from "./helpers/app";
import { waitForSidebarHydration, switchWorkspaceViaSidebar } from "./helpers/workspace-ui";
import { seedWorkspace } from "./helpers/seed-client";
import { hasGithubAuth, createTempGithubRepo } from "./helpers/github-fixtures";
import { getServerId } from "./helpers/server-id";
const MINIMAL_PNG = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
"base64",
);
const TEST_IMAGE = { name: "test.png", mimeType: "image/png", buffer: MINIMAL_PNG };
test.describe("Composer attachments", () => {
test("Plus menu shows image and GitHub options", async ({ page, withWorkspace }) => {
test.setTimeout(60_000);
const workspace = await withWorkspace({ prefix: "attach-plus-" });
await workspace.navigateTo();
await clickNewChat(page);
await expectComposerVisible(page);
await openAttachmentMenu(page);
await expect(page.getByTestId("message-input-attachment-menu-item-image")).toBeVisible();
await expect(page.getByTestId("message-input-attachment-menu-item-github")).toBeVisible();
});
test("GitHub combobox does not render until the picker is opened", async ({
page,
withWorkspace,
}) => {
test.setTimeout(60_000);
const workspace = await withWorkspace({ prefix: "attach-gh-lazy-" });
await workspace.navigateTo();
await clickNewChat(page);
await expectComposerVisible(page);
await expect(page.getByTestId("combobox-desktop-container")).not.toBeVisible();
await openGithubPickerFromMenu(page);
await expect(page.getByPlaceholder("Search issues and PRs...")).toBeVisible({ timeout: 5_000 });
await expect(
page.getByTestId("combobox-empty-text").or(page.getByText("Searching...")),
).toBeVisible({ timeout: 10_000 });
});
test("GitHub issue attachment pill visible after search and selection", async ({ page }) => {
test.setTimeout(120_000);
if (!hasGithubAuth()) {
test.skip(true, "GitHub auth not available in this environment");
}
const ghRepo = await createTempGithubRepo({
category: "attach-issue",
issues: [{ title: "fix: attach-issue-unique-alpha" }],
prs: [{ title: "feat: attach-issue-dummy-pr", state: "open" }],
});
const handle = await openGithubWorkspace(page, ghRepo.prs[0].localPath);
try {
await clickNewChat(page);
await expectComposerVisible(page);
await selectGithubOption(
page,
"attach-issue-unique-alpha",
`issue:${ghRepo.issues[0].number}`,
);
await expectGithubAttachmentPill(page, {
number: ghRepo.issues[0].number,
title: ghRepo.issues[0].title,
});
} finally {
await handle.cleanup();
await ghRepo.cleanup();
}
});
test("GitHub PR attachment pill visible after search and selection", async ({ page }) => {
test.setTimeout(120_000);
if (!hasGithubAuth()) {
test.skip(true, "GitHub auth not available in this environment");
}
const ghRepo = await createTempGithubRepo({
category: "attach-pr",
prs: [{ title: "feat: attach-pr-unique-beta", state: "open" }],
});
const handle = await openGithubWorkspace(page, ghRepo.prs[0].localPath);
try {
await clickNewChat(page);
await expectComposerVisible(page);
await selectGithubOption(page, "attach-pr-unique-beta", `pr:${ghRepo.prs[0].number}`);
await expectGithubAttachmentPill(page, {
number: ghRepo.prs[0].number,
title: ghRepo.prs[0].title,
});
} finally {
await handle.cleanup();
await ghRepo.cleanup();
}
});
test.fixme("workspace-review pill suppresses on X-click and reappears after send", async () => {
// The review attachment is created via InlineReviewEditor in surface.tsx (addComment action).
// Automating this requires: a workspace with staged changes, navigating to the diff panel,
// hovering the gutter "+" button, typing a comment, and submitting. A dedicated
// helpers/review.ts with addInlineReviewComment(page, filePath, lineNumber, comment) is
// needed before this can be exercised end-to-end.
});
test.fixme("browser-element attachment pill is created from Electron webview selection", async () => {
// The browser-element attachment is only created in browser-pane.electron.tsx via DOM
// element selection in the Electron webview. It is not exercisable in headless Chromium E2E.
});
test("image lightbox opens on pill click and closes on Escape", async ({
page,
withWorkspace,
}) => {
test.setTimeout(60_000);
const workspace = await withWorkspace({ prefix: "attach-lightbox-" });
await workspace.navigateTo();
await clickNewChat(page);
await expectComposerVisible(page);
await attachImageFromMenu(page, TEST_IMAGE);
await expectAttachmentPill(page, "composer-image-attachment-pill");
await openImageLightbox(page);
await closeImageLightbox(page);
});
test("image attachment pill renders after file is selected", async ({ page, withWorkspace }) => {
test.setTimeout(60_000);
const workspace = await withWorkspace({ prefix: "attach-pill-" });
await workspace.navigateTo();
await clickNewChat(page);
await expectComposerVisible(page);
await attachImageFromMenu(page, TEST_IMAGE);
await expectAttachmentPill(page, "composer-image-attachment-pill");
});
test("clicking the X on an image pill removes it", async ({ page, withWorkspace }) => {
test.setTimeout(60_000);
const workspace = await withWorkspace({ prefix: "attach-remove-" });
await workspace.navigateTo();
await clickNewChat(page);
await expectComposerVisible(page);
await attachImageFromMenu(page, TEST_IMAGE);
await expectAttachmentPill(page, "composer-image-attachment-pill");
await removeAttachmentPill(page, "composer-image-attachment-pill", "Remove image attachment");
await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(0, {
timeout: 5_000,
});
});
test("submitting while agent is running queues the message and clears the draft", async ({
page,
}) => {
test.setTimeout(120_000);
const agent = await startRunningMockAgent(page, {
prefix: "attach-queue-",
model: "one-minute-stream",
prompt: "Stay running for queue test.",
});
try {
await fillComposerDraft(page, "queued draft text");
await sendDraftToQueue(page);
await expectQueuedMessageButton(page);
await expectComposerDraft(page, "");
} finally {
await agent.cleanup();
}
});
test("Escape interrupt cancels the running agent and preserves composer draft", async ({
page,
}) => {
test.setTimeout(120_000);
const agent = await startRunningMockAgent(page, {
prefix: "attach-interrupt-",
model: "ten-second-stream",
prompt: "Stay running for interrupt test.",
});
try {
await fillComposerDraft(page, "preserve me");
await pressInterruptShortcut(page);
await expectAgentIdle(page, 15_000);
await expectComposerDraft(page, "preserve me");
} finally {
await agent.cleanup();
}
});
test("composer is locked while new workspace agent is being created", async ({ page }) => {
test.setTimeout(120_000);
const serverId = getServerId();
const agentCreatedDelay = await delayBrowserAgentCreatedStatus(page);
const workspace = await seedWorkspace({ repoPrefix: "attach-lock-" });
try {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await switchWorkspaceViaSidebar({
page,
serverId,
workspaceId: workspace.workspaceId,
});
await openNewWorkspaceComposer(page, {
projectKey: workspace.projectId,
projectDisplayName: workspace.projectDisplayName,
});
await fillComposerDraft(page, "lock test prompt");
const createButton = page
.getByTestId("message-input-root")
.getByRole("button", { name: "Create" });
await expect(createButton).toBeVisible({ timeout: 30_000 });
await createButton.click();
await agentCreatedDelay.waitForCreateRequest();
await agentCreatedDelay.waitForDelayedCreatedStatus();
await expectComposerDisabled(page);
await expectAttachButtonDisabled(page);
agentCreatedDelay.release();
await expectComposerEditable(page);
} finally {
agentCreatedDelay.release();
await workspace.cleanup();
}
});
});

View File

@@ -0,0 +1,483 @@
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 { daemonWsRoutePattern } from "./helpers/daemon-port";
const TEST_COMMANDS = [
{
name: "tdd",
description: "Write a red test, verify it fails for the right reason, implement to green",
argumentHint: "",
},
{
name: "help",
description: "Show help for the current agent session and available slash commands",
argumentHint: "",
},
{
name: "hello",
description: "Insert a friendly greeting prompt into the current composer",
argumentHint: "",
},
{
name: "heapdump",
description: "Dump the JavaScript heap for local desktop debugging",
argumentHint: "",
},
{
name: "health",
description: "Show runtime health checks and connection diagnostics",
argumentHint: "",
},
{
name: "history",
description: "Summarize recent session history",
argumentHint: "",
},
{
name: "handoff",
description: "Prepare a complete handoff note for another agent",
argumentHint: "[agent]",
},
{
name: "hover",
description: "Audit hover behavior in desktop web surfaces",
argumentHint: "",
},
{
name: "harness",
description: "Inspect the local test harness configuration",
argumentHint: "",
},
{
name: "hydrate",
description: "Refresh persisted state used by the current workspace",
argumentHint: "",
},
{
name: "highlight",
description: "Highlight important changes in the active diff",
argumentHint: "",
},
{
name: "home",
description: "Navigate back to the workspace home surface",
argumentHint: "",
},
{
name: "host",
description: "Inspect host connection metadata",
argumentHint: "",
},
] as const;
interface PopoverFrame {
exists: boolean;
top: number;
bottom: number;
height: number;
opacity: number;
display: string;
visibility: string;
timestamp: number;
}
interface PopoverFrameRecorderWindow extends Window {
__composerAutocompleteFrames?: PopoverFrame[];
__stopComposerAutocompleteFrameRecorder?: () => void;
}
async function getTopTestIdAtPoint(page: Page, x: number, y: number) {
return page.evaluate(
([pointX, pointY]) => {
const element = document.elementFromPoint(pointX, pointY);
return element?.closest("[data-testid]")?.getAttribute("data-testid") ?? null;
},
[x, y],
);
}
async function installListCommandsStub(page: Page): Promise<void> {
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const server = ws.connectToServer();
ws.onMessage((message) => {
server.send(message);
});
server.onMessage((message) => {
if (typeof message !== "string") {
ws.send(message);
return;
}
try {
const parsed = JSON.parse(message) as {
type?: string;
message?: {
type?: string;
payload?: {
commands?: unknown;
error?: string | null;
};
};
};
if (
parsed.type === "session" &&
parsed.message?.type === "list_commands_response" &&
parsed.message.payload
) {
parsed.message.payload.commands = TEST_COMMANDS;
parsed.message.payload.error = null;
ws.send(JSON.stringify(parsed));
return;
}
} catch {
// Forward non-JSON frames unchanged.
}
ws.send(message);
});
});
}
async function openReadyMockAgent(
page: Page,
options?: { expectWorkspaceTab?: boolean },
): Promise<{
cleanup: () => Promise<void>;
}> {
const session = await seedMockAgentWorkspace({
repoPrefix: "autocomplete-popover-",
title: "Autocomplete popover regression",
});
try {
await openAgentRoute(page, session);
if (options?.expectWorkspaceTab !== false) {
await expectWorkspaceTabVisible(page, session.agentId);
}
await expectComposerVisible(page);
return { cleanup: session.cleanup };
} catch (error) {
await session.cleanup();
throw error;
}
}
async function visiblePopoverBox(
page: Page,
): Promise<{ top: number; bottom: number; height: number }> {
const popover = page.getByTestId("composer-autocomplete-popover");
await expect(popover).toBeVisible({ timeout: 30_000 });
await expect
.poll(
async () =>
popover.evaluate((element) => {
const style = window.getComputedStyle(element);
return Number(style.opacity);
}),
{ timeout: 30_000 },
)
.toBeGreaterThan(0.95);
const box = await popover.boundingBox();
if (!box) {
throw new Error("Autocomplete popover did not produce a bounding box.");
}
return {
top: box.y,
bottom: box.y + box.height,
height: box.height,
};
}
async function startPopoverFrameRecorder(page: Page): Promise<void> {
await page.evaluate(() => {
const win = window as PopoverFrameRecorderWindow;
win.__composerAutocompleteFrames = [];
let active = true;
const record = () => {
if (!active) return;
const element = document.querySelector('[data-testid="composer-autocomplete-popover"]');
if (element instanceof HTMLElement) {
const rect = element.getBoundingClientRect();
const style = window.getComputedStyle(element);
win.__composerAutocompleteFrames?.push({
exists: true,
top: rect.top,
bottom: rect.bottom,
height: rect.height,
opacity: Number(style.opacity),
display: style.display,
visibility: style.visibility,
timestamp: performance.now(),
});
} else {
win.__composerAutocompleteFrames?.push({
exists: false,
top: 0,
bottom: 0,
height: 0,
opacity: 0,
display: "none",
visibility: "hidden",
timestamp: performance.now(),
});
}
window.requestAnimationFrame(record);
};
win.__stopComposerAutocompleteFrameRecorder = () => {
active = false;
};
window.requestAnimationFrame(record);
});
}
async function stopPopoverFrameRecorder(page: Page): Promise<PopoverFrame[]> {
return page.evaluate(() => {
const win = window as PopoverFrameRecorderWindow;
win.__stopComposerAutocompleteFrameRecorder?.();
return win.__composerAutocompleteFrames ?? [];
});
}
function visiblePopoverFrames(frames: PopoverFrame[]): PopoverFrame[] {
return frames.filter(
(frame) =>
frame.exists &&
frame.height > 0 &&
frame.opacity > 0.95 &&
frame.display !== "none" &&
frame.visibility !== "hidden",
);
}
function formatFrame(frame: PopoverFrame | undefined): string {
if (!frame) return "none";
return JSON.stringify({
top: Math.round(frame.top),
bottom: Math.round(frame.bottom),
height: Math.round(frame.height),
opacity: frame.opacity,
});
}
function expectPopoverFramesStable(frames: PopoverFrame[]): void {
const visibleFrames = visiblePopoverFrames(frames);
expect(visibleFrames.length).toBeGreaterThan(0);
const finalFrame = visibleFrames[visibleFrames.length - 1];
const jumpingFrame = visibleFrames.find(
(frame) =>
Math.abs(frame.top - finalFrame.top) > 4 ||
Math.abs(frame.bottom - finalFrame.bottom) > 4 ||
Math.abs(frame.height - finalFrame.height) > 4,
);
expect(
jumpingFrame,
`expected first visible popover paint to be stable; first=${formatFrame(
visibleFrames[0],
)} jumping=${formatFrame(jumpingFrame)} final=${formatFrame(finalFrame)}`,
).toBeUndefined();
}
function expectPopoverDoesNotDisappearAfterFirstVisible(frames: PopoverFrame[]): void {
const firstVisibleIndex = frames.findIndex(
(frame) =>
frame.exists &&
frame.height > 0 &&
frame.opacity > 0.95 &&
frame.display !== "none" &&
frame.visibility !== "hidden",
);
expect(firstVisibleIndex).toBeGreaterThanOrEqual(0);
const hiddenFrame = frames
.slice(firstVisibleIndex)
.find((frame) => !frame.exists || frame.opacity < 0.95);
expect(
hiddenFrame,
`expected mounted popover to stay visible while filtering; hidden=${formatFrame(hiddenFrame)}`,
).toBeUndefined();
}
test.describe("Composer autocomplete", () => {
test("does not flash at the wrong position on the first slash command paint", async ({
page,
}) => {
await installListCommandsStub(page);
const agent = await openReadyMockAgent(page);
try {
const input = composerLocator(page);
await expect(input).toBeEditable({ timeout: 30_000 });
await input.click();
await startPopoverFrameRecorder(page);
await page.keyboard.type("/");
await expect(page.getByText("/help", { exact: true })).toBeVisible({ timeout: 30_000 });
await page.waitForTimeout(250);
const frames = await stopPopoverFrameRecorder(page);
expectPopoverFramesStable(frames);
} finally {
await agent.cleanup();
}
});
test("does not jump when deleting a slash command search", async ({ page }) => {
await installListCommandsStub(page);
const agent = await openReadyMockAgent(page);
try {
const input = composerLocator(page);
await expect(input).toBeEditable({ timeout: 30_000 });
await input.fill("/he");
const popover = page.getByTestId("composer-autocomplete-popover");
await expect(popover.getByText("/help", { exact: true }).first()).toBeVisible({
timeout: 30_000,
});
const beforeDelete = await visiblePopoverBox(page);
await input.press("Backspace");
await expect(input).toHaveValue("/h");
await expect(popover.getByText("/history", { exact: true }).first()).toBeVisible({
timeout: 30_000,
});
const afterDelete = await visiblePopoverBox(page);
expect(Math.abs(afterDelete.bottom - beforeDelete.bottom)).toBeLessThanOrEqual(4);
expect(afterDelete.height).toBeGreaterThan(beforeDelete.height);
} finally {
await agent.cleanup();
}
});
test("shrinks to filtered slash command results without moving the bottom edge", async ({
page,
}) => {
await installListCommandsStub(page);
const agent = await openReadyMockAgent(page);
try {
const input = composerLocator(page);
await expect(input).toBeEditable({ timeout: 30_000 });
await input.fill("/");
const popover = page.getByTestId("composer-autocomplete-popover");
await expect(popover.getByText("/help", { exact: true }).first()).toBeVisible({
timeout: 30_000,
});
const allCommands = await visiblePopoverBox(page);
await input.fill("/tdd");
await expect(popover.getByText("/tdd", { exact: true }).first()).toBeVisible({
timeout: 30_000,
});
const oneCommand = await visiblePopoverBox(page);
expect(Math.abs(oneCommand.bottom - allCommands.bottom)).toBeLessThanOrEqual(4);
expect(oneCommand.height).toBeLessThan(allCommands.height - 40);
} finally {
await agent.cleanup();
}
});
test("stays visible while filtering slash command results", async ({ page }) => {
await installListCommandsStub(page);
const agent = await openReadyMockAgent(page);
try {
const input = composerLocator(page);
await expect(input).toBeEditable({ timeout: 30_000 });
await input.fill("/");
const popover = page.getByTestId("composer-autocomplete-popover");
await expect(popover.getByText("/help", { exact: true }).first()).toBeVisible({
timeout: 30_000,
});
await startPopoverFrameRecorder(page);
await page.keyboard.type("tdd", { delay: 40 });
await expect(popover.getByText("/tdd", { exact: true }).first()).toBeVisible({
timeout: 30_000,
});
await page.waitForTimeout(250);
const frames = await stopPopoverFrameRecorder(page);
expectPopoverDoesNotDisappearAfterFirstVisible(frames);
} finally {
await agent.cleanup();
}
});
test("stays anchored to the composer when the desktop sidebar is open", async ({ page }) => {
await installListCommandsStub(page);
const agent = await openReadyMockAgent(page);
try {
await expect(page.getByTestId("sidebar-sessions")).toBeVisible({ timeout: 30_000 });
const input = composerLocator(page);
await expect(input).toBeEditable({ timeout: 30_000 });
await input.fill("/");
const popover = page.getByTestId("composer-autocomplete-popover");
await expect(popover.getByText("/help", { exact: true }).first()).toBeVisible({
timeout: 30_000,
});
const composerBox = await page.getByTestId("message-input-root").boundingBox();
const popoverBox = await popover.boundingBox();
expect(composerBox).not.toBeNull();
expect(popoverBox).not.toBeNull();
expect(Math.abs(popoverBox!.x - composerBox!.x)).toBeLessThanOrEqual(4);
expect(Math.abs(popoverBox!.width - composerBox!.width)).toBeLessThanOrEqual(4);
} finally {
await agent.cleanup();
}
});
test.describe("compact sidebar layering", () => {
test.use({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true });
test("keeps the mobile agent sidebar above autocomplete", async ({ page }) => {
await installListCommandsStub(page);
const agent = await openReadyMockAgent(page, { expectWorkspaceTab: false });
try {
const input = composerLocator(page);
await expect(input).toBeEditable({ timeout: 30_000 });
await input.fill("/");
const popover = page.getByTestId("composer-autocomplete-popover");
await expect(popover.getByText("/help", { exact: true }).first()).toBeVisible({
timeout: 30_000,
});
await page.getByRole("button", { name: "Open menu" }).click();
await expect(page.getByTestId("sidebar-sessions")).toBeInViewport({ timeout: 5_000 });
const popoverBox = await popover.boundingBox();
expect(popoverBox).not.toBeNull();
const topTestId = await getTopTestIdAtPoint(
page,
popoverBox!.x + popoverBox!.width / 2,
popoverBox!.y + popoverBox!.height / 2,
);
expect(topTestId).not.toBe("composer-autocomplete-popover");
} finally {
await agent.cleanup();
}
});
});
});

View File

@@ -0,0 +1,144 @@
import { test } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import { getServerId } from "./helpers/server-id";
import {
loadRealDaemonState,
injectDesktopBridge,
openDesktopSettings,
expectUpdateBanner,
clickInstallUpdate,
expectInstallInProgress,
interceptDaemonManagementConfirmDialog,
toggleDaemonManagement,
expectDaemonManagementConfirmDialog,
expectDaemonManagementEnabled,
expectDaemonManagementDisabled,
expectDaemonStatusPid,
expectDaemonStatusLogPath,
expectDaemonStatusVersion,
} from "./helpers/desktop-updates";
// No Playwright Electron runner exists; we simulate the desktop bridge via
// addInitScript so Electron-gated UI activates without a real Electron process.
test.describe("Desktop updates", () => {
test("update banner appears in the sidebar when an app update is available", async ({ page }) => {
await injectDesktopBridge(page, {
serverId: getServerId(),
updateAvailable: true,
latestVersion: "1.2.3",
});
await gotoAppShell(page);
await expectUpdateBanner(page, "1.2.3");
});
test("clicking install shows the installing state on the callout", async ({ page }) => {
await injectDesktopBridge(page, {
serverId: getServerId(),
updateAvailable: true,
latestVersion: "1.2.3",
slowInstall: true,
});
await gotoAppShell(page);
await expectUpdateBanner(page, "1.2.3");
await clickInstallUpdate(page);
await expectInstallInProgress(page);
});
});
test.describe("Desktop daemon management", () => {
test("disabling built-in daemon management shows confirm dialog with correct copy", async ({
page,
}) => {
const serverId = getServerId();
await injectDesktopBridge(page, {
serverId,
manageBuiltInDaemon: true,
confirmShouldAccept: false,
});
await gotoAppShell(page);
await openDesktopSettings(page, serverId);
const dialogArgs = await interceptDaemonManagementConfirmDialog(page);
expectDaemonManagementConfirmDialog(dialogArgs);
await expectDaemonManagementEnabled(page);
});
test("cancelling the confirm dialog leaves the daemon management toggle on", async ({ page }) => {
const serverId = getServerId();
await injectDesktopBridge(page, {
serverId,
manageBuiltInDaemon: true,
confirmShouldAccept: false,
});
await gotoAppShell(page);
await openDesktopSettings(page, serverId);
await expectDaemonManagementEnabled(page);
await toggleDaemonManagement(page, "disable");
await expectDaemonManagementEnabled(page);
});
test("confirming the dialog disables built-in daemon management", async ({ page }) => {
const serverId = getServerId();
await injectDesktopBridge(page, {
serverId,
manageBuiltInDaemon: true,
confirmShouldAccept: true,
});
await gotoAppShell(page);
await openDesktopSettings(page, serverId);
await toggleDaemonManagement(page, "disable");
await expectDaemonManagementDisabled(page);
});
test("daemon status panel renders version, PID, and log path from the real daemon", async ({
page,
}) => {
const serverId = getServerId();
const realState = await loadRealDaemonState();
await injectDesktopBridge(page, {
serverId,
manageBuiltInDaemon: false,
daemonPid: realState.pid,
daemonVersion: realState.version,
daemonLogPath: realState.logPath,
});
await gotoAppShell(page);
await openDesktopSettings(page, serverId);
await expectDaemonStatusVersion(page, realState.version);
await expectDaemonStatusPid(page, realState.pid);
await expectDaemonStatusLogPath(page, realState.logPath);
});
test("stopping and restarting the daemon updates the PID", async ({ page }) => {
const serverId = getServerId();
const realState = await loadRealDaemonState();
await injectDesktopBridge(page, {
serverId,
manageBuiltInDaemon: true,
daemonPid: realState.pid,
daemonVersion: realState.version,
daemonLogPath: realState.logPath,
confirmShouldAccept: true,
});
await gotoAppShell(page);
await openDesktopSettings(page, serverId);
await expectDaemonStatusPid(page, realState.pid);
await toggleDaemonManagement(page, "disable");
await expectDaemonManagementDisabled(page);
await expectDaemonStatusPid(page, null);
await toggleDaemonManagement(page, "enable");
await expectDaemonManagementEnabled(page);
const newPid = realState.pid !== null ? realState.pid + 1000 : 11000;
await expectDaemonStatusPid(page, newPid);
});
});

View File

@@ -0,0 +1,530 @@
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 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", wrapLines: false, hideWhitespace: false }),
);
},
{ preferencesKey: CHANGES_PREFERENCES_KEY },
);
}
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,192 @@
import path from "node:path";
import { test, expect, type Page } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
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 hideWorkspaceFromSidebar(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();
// Hiding a checkout from the sidebar raises a browser confirm; accept it so the
// user-confirmed archive proceeds deterministically.
page.once("dialog", (dialog) => void dialog.accept());
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 page.getByTestId("sidebar-add-project").click();
const input = page.getByPlaceholder("Type a directory path...");
await expect(input).toBeVisible({ timeout: 30_000 });
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 });
}
// 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 });
await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toBeVisible({
timeout: 30_000,
});
await hideWorkspaceFromSidebar(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,
});
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?.projectDisplayName).toBe(workspace.projectDisplayName);
await page.reload();
await waitForSidebarHydration(page);
await expect(projectRow).toBeVisible({ timeout: 30_000 });
await expect(projectRow).toContainText(workspace.projectDisplayName);
await expect(projectRow).not.toContainText(workspace.repoPath);
await expect(
page.getByTestId(`sidebar-project-new-workspace-row-${workspace.projectId}`),
).toBeVisible({ timeout: 30_000 });
} finally {
await workspace.cleanup();
}
});
});

View File

@@ -9,41 +9,31 @@ import {
openFileFromExplorer,
} from "./helpers/file-explorer";
import { gotoWorkspace } from "./helpers/launcher";
import { createTempGitRepo } from "./helpers/workspace";
import {
connectWorkspaceSetupClient,
type WorkspaceSetupDaemonClient,
} from "./helpers/workspace-setup";
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
let tempRepo: { path: string; cleanup: () => Promise<void> };
let workspaceId: string;
let seedClient: WorkspaceSetupDaemonClient;
let workspace: SeededWorkspace;
test.beforeAll(async () => {
tempRepo = await createTempGitRepo("file-explorer-collapse-", {
files: [
{ path: "assets/logo.png", content: "image bytes for explorer e2e\n" },
{ path: "docs/guide.md", content: "# Guide\n" },
],
workspace = await seedWorkspace({
repoPrefix: "file-explorer-collapse-",
repo: {
files: [
{ path: "assets/logo.png", content: "image bytes for explorer e2e\n" },
{ path: "docs/guide.md", content: "# Guide\n" },
],
},
});
seedClient = await connectWorkspaceSetupClient();
const result = await seedClient.openProject(tempRepo.path);
if (!result.workspace) {
throw new Error(result.error ?? "Failed to seed workspace");
}
workspaceId = String(result.workspace.id);
});
test.afterAll(async () => {
await seedClient?.close();
await tempRepo?.cleanup();
await workspace?.cleanup();
});
test.describe("File explorer collapse", () => {
test("collapses an opened image file parent folder and still expands other folders", async ({
page,
}) => {
await gotoWorkspace(page, workspaceId);
await gotoWorkspace(page, workspace.workspaceId);
await openFileExplorer(page);
await expandFolder(page, "assets");

View File

@@ -1,8 +1,14 @@
import { test as base, expect, type Page } from "@playwright/test";
import { getE2EDaemonPort } from "./helpers/daemon-port";
import { buildCreateAgentPreferences, buildSeededHost } from "./helpers/daemon-registry";
import { createWithWorkspace, type WithWorkspace } from "./helpers/with-workspace";
// Extend base test to provide dynamic baseURL from global-setup
const test = base.extend({
// 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 }>({
baseURL: async ({}, provide) => {
const metroPort = process.env.E2E_METRO_PORT;
if (!metroPort) {
@@ -10,102 +16,87 @@ const test = base.extend({
}
await provide(`http://localhost:${metroPort}`);
},
});
const consoleEntries = new WeakMap<Page, string[]>();
test.beforeEach(async ({ page }) => {
const daemonPort = process.env.E2E_DAEMON_PORT;
const metroPort = process.env.E2E_METRO_PORT;
if (!daemonPort) {
throw new Error(
"E2E_DAEMON_PORT is not set. Refusing to run e2e against the default daemon (e.g. localhost:6767). " +
"Ensure Playwright `globalSetup` starts the e2e daemon and exports E2E_DAEMON_PORT.",
);
}
if (daemonPort === "6767") {
throw new Error(
"E2E_DAEMON_PORT is 6767. Refusing to run e2e against the default local daemon. " +
"Fix Playwright globalSetup to start an isolated test daemon and export its port.",
);
}
if (!metroPort) {
throw new Error(
"E2E_METRO_PORT is not set. Ensure Playwright `globalSetup` starts Metro and exports E2E_METRO_PORT.",
);
}
// Hard guardrail: never allow tests to hit the developer's default daemon.
// This blocks both HTTP and WS attempts to :6767 (before any navigation).
await page.route(/:(6767)\b/, (route) => route.abort());
await page.routeWebSocket(/:(6767)\b/, async (ws) => {
await ws.close({ code: 1008, reason: "Blocked connection to localhost:6767 during e2e." });
});
const entries: string[] = [];
consoleEntries.set(page, entries);
page.on("console", (message) => {
entries.push(`[console:${message.type()}] ${message.text()}`);
});
page.on("pageerror", (error) => {
entries.push(`[pageerror] ${error.message}`);
});
const nowIso = new Date().toISOString();
const seedNonce = Math.random().toString(36).slice(2);
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set - expected from Playwright globalSetup.");
}
const testDaemon = buildSeededHost({
serverId,
endpoint: `127.0.0.1:${daemonPort}`,
nowIso,
});
const createAgentPreferences = buildCreateAgentPreferences(testDaemon.serverId);
await page.addInitScript(
({ daemon, preferences, seedNonce: nonce }) => {
// `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.
const disableOnceKey = "@paseo:e2e-disable-default-seed-once";
const disableValue = localStorage.getItem(disableOnceKey);
if (disableValue) {
localStorage.removeItem(disableOnceKey);
if (disableValue === nonce) {
return;
}
paseoE2ESetup: [
async ({ page }, provide, testInfo) => {
const daemonPort = getE2EDaemonPort();
const metroPort = process.env.E2E_METRO_PORT;
if (!metroPort) {
throw new Error(
"E2E_METRO_PORT is not set. Ensure Playwright `globalSetup` starts Metro and exports E2E_METRO_PORT.",
);
}
localStorage.setItem("@paseo:e2e", "1");
localStorage.setItem("@paseo:e2e-seed-nonce", nonce);
// Hard guardrail: never allow tests to hit the developer's default daemon.
// This blocks both HTTP and WS attempts to :6767 (before any navigation).
await page.route(/:(6767)\b/, (route) => route.abort());
await page.routeWebSocket(/:(6767)\b/, async (ws) => {
await ws.close({ code: 1008, reason: "Blocked connection to localhost:6767 during e2e." });
});
// Hard-reset anything that could point to a developer's real daemon.
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon]));
localStorage.removeItem("@paseo:settings");
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
const entries: string[] = [];
page.on("console", (message) => {
entries.push(`[console:${message.type()}] ${message.text()}`);
});
page.on("pageerror", (error) => {
entries.push(`[pageerror] ${error.message}`);
});
const nowIso = new Date().toISOString();
const seedNonce = Math.random().toString(36).slice(2);
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set - expected from Playwright globalSetup.");
}
const testDaemon = buildSeededHost({
serverId,
endpoint: `127.0.0.1:${daemonPort}`,
nowIso,
});
const createAgentPreferences = buildCreateAgentPreferences(testDaemon.serverId);
await page.addInitScript(
({ daemon, preferences, seedNonce: nonce }) => {
// `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.
const disableOnceKey = "@paseo:e2e-disable-default-seed-once";
const disableValue = localStorage.getItem(disableOnceKey);
if (disableValue) {
localStorage.removeItem(disableOnceKey);
if (disableValue === nonce) {
return;
}
}
localStorage.setItem("@paseo:e2e", "1");
localStorage.setItem("@paseo:e2e-seed-nonce", nonce);
// Hard-reset anything that could point to a developer's real daemon.
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon]));
localStorage.removeItem("@paseo:settings");
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
},
{ daemon: testDaemon, preferences: createAgentPreferences, seedNonce },
);
await provide();
if (entries.length > 0 && testInfo.status !== testInfo.expectedStatus) {
await testInfo.attach("browser-console", {
body: entries.join("\n"),
contentType: "text/plain",
});
}
},
{ daemon: testDaemon, preferences: createAgentPreferences, seedNonce },
);
});
test.afterEach(async ({ page }, testInfo) => {
const entries = consoleEntries.get(page);
if (!entries || entries.length === 0) {
return;
}
if (testInfo.status === testInfo.expectedStatus) {
return;
}
await testInfo.attach("browser-console", {
body: entries.join("\n"),
contentType: "text/plain",
});
{ auto: true },
],
withWorkspace: async ({ page }, provide) => {
const handle = createWithWorkspace(page);
await provide(handle.withWorkspace);
await handle.cleanup();
},
});
export { test, expect, type Page };

View File

@@ -1,11 +1,14 @@
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";
interface WaitForServerOptions {
@@ -20,7 +23,7 @@ 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 +34,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 {
@@ -143,6 +154,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 +260,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 fakeGhBinDir: string | null = null;
let fakeEditorBinDir: string | null = null;
let relayProcess: ChildProcess | null = null;
function resolveOptionalPaseoHomeEnv(value: string | undefined): string | null {
@@ -209,49 +281,45 @@ interface OfferPayload {
relay: { endpoint: string };
}
async function createFakeGhBin(): Promise<string> {
const binDir = await mkdtemp(path.join(tmpdir(), "paseo-e2e-gh-bin-"));
const ghPath = path.join(binDir, "gh");
await writeFile(
ghPath,
`#!/usr/bin/env node
const args = process.argv.slice(2);
if (args[0] === "auth" && args[1] === "status") {
process.exit(0);
interface DaemonClientConfig {
url: string;
clientId: string;
clientType: "cli";
webSocketFactory: NodeWebSocketFactory;
}
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);
interface PairingDaemonClient {
connect(): Promise<void>;
close(): Promise<void>;
getDaemonPairingOffer(): Promise<{
relayEnabled: boolean;
url: string;
}>;
}
if (args[0] === "pr" && args[1] === "view" && args[2] === "--json" && args[3]) {
console.error("no pull requests found for branch");
process.exit(1);
}
async function createFakeEditorBin(): Promise<string> {
const binDir = await mkdtemp(path.join(tmpdir(), "paseo-e2e-editor-bin-"));
if (args[0] === "issue" && args[1] === "list") {
console.log("[]");
process.exit(0);
}
const fakeEditorSource = `#!/usr/bin/env node
const fs = require("fs");
const path = require("path");
const recordPath = process.env.PASEO_E2E_EDITOR_RECORD_PATH;
if (recordPath) {
fs.appendFileSync(recordPath, JSON.stringify({
command: path.basename(process.argv[1]),
args: process.argv.slice(2),
cwd: process.cwd(),
at: Date.now()
}) + "\\n");
}
`;
for (const editorCommand of ["cursor", "code"]) {
const editorPath = path.join(binDir, editorCommand);
await writeFile(editorPath, fakeEditorSource);
await chmod(editorPath, 0o755);
}
console.error("Unsupported fake gh invocation: " + args.join(" "));
process.exit(1);
`,
);
await chmod(ghPath, 0o755);
return binDir;
}
@@ -268,7 +336,7 @@ function ensureRelayBuildArtifact(repoRoot: string): void {
}
console.log("[e2e] Building @getpaseo/relay for daemon startup");
execSync("npm run build --workspace=@getpaseo/relay", {
execSync("npm run build:relay", {
cwd: repoRoot,
stdio: "inherit",
});
@@ -290,29 +358,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;
@@ -321,7 +389,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);
@@ -329,16 +397,20 @@ 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;
}
const LOCAL_SPEECH_ENV_KEYS = [
"PASEO_LOCAL_MODELS_DIR",
"PASEO_DICTATION_LOCAL_STT_MODEL",
"PASEO_VOICE_LOCAL_STT_MODEL",
"PASEO_VOICE_LOCAL_TTS_MODEL",
"PASEO_VOICE_LOCAL_TTS_SPEAKER_ID",
"PASEO_VOICE_LOCAL_TTS_SPEED",
] as const;
async function loadEnvTestFile(repoRoot: string): Promise<void> {
const envTestPath = path.join(repoRoot, ".env.test");
@@ -372,7 +444,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 ?? "",
@@ -382,20 +454,21 @@ async function resolveDictationConfig(): Promise<DictationConfig> {
);
const hasDefaultLocalModelsDir =
defaultLocalModelsDir.trim().length > 0 && existsSync(defaultLocalModelsDir);
const dictationProvider = openAiUsable ? "openai" : "local";
if (dictationProvider === "local" && !hasDefaultLocalModelsDir) {
throw new Error(
"OpenAI key is not usable and local speech models are unavailable at ~/.paseo/models/local-speech. " +
"Either provide a valid OPENAI_API_KEY or install local speech models before running app e2e tests.",
// 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 — app E2E keeps dictation/voice disabled. " +
"Tests that require dictation should gate on PASEO_DICTATION_ENABLED.",
);
return;
}
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 {
@@ -480,13 +553,22 @@ async function awaitRelayReady(
}
}
async function startRelay(): Promise<number> {
async function getAvailablePortExcluding(excludedPorts: Set<number>): Promise<number> {
for (;;) {
const port = await getAvailablePort();
if (!excludedPorts.has(port) && !RESERVED_LOCAL_PORTS.has(port)) {
return port;
}
}
}
async function startRelay(excludedPorts: Set<number>): Promise<number> {
const relayDir = path.resolve(__dirname, "..", "..", "relay");
const maxRelayStartupAttempts = 5;
let lastRelayStartupError: unknown = null;
for (let attempt = 1; attempt <= maxRelayStartupAttempts; attempt += 1) {
const relayPort = await getAvailablePort();
const relayPort = await getAvailablePortExcluding(excludedPorts);
const buffer = createLineBuffer();
const state: RelayStreamState = { failureLine: null, readyForSelectedPort: false };
@@ -521,13 +603,23 @@ async function startRelay(): 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,
@@ -539,7 +631,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}`);
}
});
@@ -550,7 +642,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}`);
}
});
@@ -563,39 +655,42 @@ interface DaemonSpawnArgs {
relayPort: number;
metroPort: number;
paseoHome: string;
fakeGhBinDir: string;
dictation: DictationConfig;
fakeEditorBinDir: string;
editorRecordPath: string;
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: NodeJS.ProcessEnv = {
...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: `0.0.0.0:${args.port}`,
PASEO_RELAY_ENDPOINT: `127.0.0.1:${args.relayPort}`,
PASEO_CORS_ORIGINS: `http://localhost:${args.metroPort}`,
// Default app E2E does not cover speech flows. Keep these disabled so
// unrelated tests never start background local-model downloads.
PASEO_DICTATION_ENABLED: "0",
PASEO_VOICE_MODE_ENABLED: "0",
PASEO_DICTATION_STT_PROVIDER: "openai",
PASEO_VOICE_TURN_DETECTION_PROVIDER: "openai",
PASEO_VOICE_STT_PROVIDER: "openai",
PASEO_VOICE_TTS_PROVIDER: "openai",
PASEO_NODE_ENV: "development",
NODE_ENV: "development",
};
const child = spawn(tsxBin, ["src/server/index.ts"], {
for (const key of LOCAL_SPEECH_ENV_KEYS) {
delete env[key];
}
const child = spawn(tsxBin, ["scripts/supervisor-entrypoint.ts", "--dev"], {
cwd: serverDir,
env: {
...process.env,
PATH: `${args.fakeGhBinDir}${path.delimiter}${process.env.PATH ?? ""}`,
PASEO_HOME: args.paseoHome,
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,
});
@@ -627,24 +722,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 (fakeGhBinDir) {
await rm(fakeGhBinDir, { recursive: true, force: true });
fakeGhBinDir = null;
if (fakeEditorBinDir) {
await removeTempTree(fakeEditorBinDir);
fakeEditorBinDir = null;
}
}
@@ -653,12 +758,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-")));
fakeGhBinDir = await createFakeGhBin();
const editorRecordPath = path.join(paseoHome, "editor-open-records.jsonl");
fakeEditorBinDir = await createFakeEditorBin();
const metroLineBuffer = createLineBuffer();
const daemonLineBuffer = createLineBuffer();
@@ -666,18 +772,22 @@ export default async function globalSetup() {
const cleanup = () => performCleanup(shouldRemovePaseoHome);
const dictation = await resolveDictationConfig();
await logSpeechHarnessConfig();
try {
const relayPort = await startRelay();
metroProcess = startMetro(metroPort, metroLineBuffer);
const relayPort = await startRelay(new Set([port, metroPort]));
metroProcess = startMetro({
metroPort,
daemonPort: port,
buffer: metroLineBuffer,
});
daemonProcess = startDaemon({
port,
relayPort,
metroPort,
paseoHome,
fakeGhBinDir,
dictation,
fakeEditorBinDir,
editorRecordPath,
buffer: daemonLineBuffer,
});
@@ -695,9 +805,8 @@ export default async function globalSetup() {
}),
]);
const offer = await waitForPairingOfferFromCli({
repoRoot,
paseoHome,
const offer = await waitForPairingOfferFromDaemon({
port,
});
process.env.E2E_DAEMON_PORT = String(port);
@@ -705,6 +814,8 @@ export default async function globalSetup() {
process.env.E2E_SERVER_ID = offer.serverId;
process.env.E2E_RELAY_DAEMON_PUBLIC_KEY = offer.daemonPublicKeyB64;
process.env.E2E_METRO_PORT = String(metroPort);
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}`,
);

View File

@@ -1,9 +1,4 @@
import { expect, type Page } from "@playwright/test";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { randomUUID } from "node:crypto";
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
const NEAR_BOTTOM_THRESHOLD_PX = 72;
@@ -14,157 +9,6 @@ export interface ScrollMetrics {
distanceFromBottom: number;
}
export interface SeededAgent {
id: string;
title: string;
expectedTailText: string;
url: string;
workspaceUrl: string;
}
export interface DaemonClientInstance {
connect(): Promise<void>;
close(): Promise<void>;
createAgent(options: {
provider: string;
model: string;
thinkingOptionId: string;
modeId: string;
cwd: string;
title: string;
initialPrompt: string;
}): Promise<{ id: string }>;
sendAgentMessage(agentId: string, text: string): Promise<void>;
waitForFinish(agentId: string, timeout?: number): Promise<{ status: string }>;
}
function getDaemonWsUrl(): string {
const daemonPort = process.env.E2E_DAEMON_PORT;
if (!daemonPort) {
throw new Error("E2E_DAEMON_PORT is not set.");
}
return `ws://127.0.0.1:${daemonPort}/ws`;
}
function getServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
return serverId;
}
function buildReplyBlock(label: string, lineCount = 14): string {
return Array.from({ length: lineCount }, (_, index) => {
const line = (index + 1).toString().padStart(2, "0");
return `${label} line ${line} anchor verification text keeps wrapping stable across resize and composer growth.`;
}).join("\n");
}
function buildProtocolMessage(label: string, lineCount = 14): string {
return [
"For every message in this chat, reply with exactly the text after the final line `REPLY:`.",
"Do not add extra words, bullets, markdown fences, or tool calls.",
"REPLY:",
buildReplyBlock(label, lineCount),
].join("\n");
}
function buildReplyMessage(label: string, lineCount = 14): string {
return ["REPLY:", buildReplyBlock(label, lineCount)].join("\n");
}
export function createReplyTurn(label: string): {
message: string;
expectedReply: string;
} {
return {
message: buildReplyMessage(label),
expectedReply: buildReplyBlock(label),
};
}
interface DaemonClientConfig {
url: string;
clientId: string;
clientType: "cli";
webSocketFactory?: NodeWebSocketFactory;
}
async function loadDaemonClientConstructor(): Promise<
new (config: DaemonClientConfig) => DaemonClientInstance
> {
const repoRoot = path.resolve(__dirname, "../../../../");
const moduleUrl = pathToFileURL(
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
).href;
const mod = (await import(moduleUrl)) as {
DaemonClient: new (config: DaemonClientConfig) => DaemonClientInstance;
};
return mod.DaemonClient;
}
export async function connectDaemonClient(): Promise<DaemonClientInstance> {
const DaemonClient = await loadDaemonClientConstructor();
const webSocketFactory = createNodeWebSocketFactory();
const client = new DaemonClient({
url: getDaemonWsUrl(),
clientId: `app-e2e-${randomUUID()}`,
clientType: "cli",
webSocketFactory,
});
await client.connect();
return client;
}
export async function seedBottomAnchorAgent(input: {
client: DaemonClientInstance;
cwd: string;
title?: string;
turnCount?: number;
lineCount?: number;
}): Promise<SeededAgent> {
const title = input.title ?? `bottom-anchor-${Date.now()}`;
const turnCount = Math.max(3, input.turnCount ?? 5);
const lineCount = Math.max(14, input.lineCount ?? 14);
const created = await input.client.createAgent({
provider: "codex",
model: "gpt-5.4-mini",
thinkingOptionId: "low",
modeId: "full-access",
cwd: input.cwd,
title,
initialPrompt: buildProtocolMessage(`${title}-turn-00`, lineCount),
});
const initialFinish = await input.client.waitForFinish(created.id, 120000);
if (initialFinish.status !== "idle") {
throw new Error(
`Expected seeded agent ${created.id} to become idle after initial prompt, got ${initialFinish.status}.`,
);
}
let expectedTailText = buildReplyBlock(`${title}-turn-00`, lineCount);
for (let index = 1; index < turnCount; index += 1) {
const label = `${title}-turn-${index.toString().padStart(2, "0")}`;
expectedTailText = buildReplyBlock(label, lineCount);
await input.client.sendAgentMessage(created.id, buildReplyMessage(label, lineCount));
const finish = await input.client.waitForFinish(created.id, 120000);
if (finish.status !== "idle") {
throw new Error(
`Expected seeded agent ${created.id} to become idle after turn ${index}, got ${finish.status}.`,
);
}
}
return {
id: created.id,
title,
expectedTailText,
url: `${buildHostWorkspaceRoute(getServerId(), input.cwd)}?open=${encodeURIComponent(`agent:${created.id}`)}`,
workspaceUrl: buildHostWorkspaceRoute(getServerId(), input.cwd),
};
}
function getVisibleChatScroll(page: Page) {
return page.locator('[data-testid="agent-chat-scroll"]:visible').first();
}
@@ -201,48 +45,6 @@ export async function readScrollMetrics(page: Page): Promise<ScrollMetrics> {
});
}
export async function scrollUpFromBottom(page: Page, pixels: number): Promise<void> {
const scrollViewport = getVisibleChatScroll(page);
await expect(scrollViewport).toHaveCount(1, { timeout: 30000 });
let remaining = Math.max(0, pixels);
while (remaining > 0) {
const delta = Math.min(240, remaining);
await scrollViewport.evaluate((element: Element, step: number) => {
const scrollContainer = element as HTMLElement;
scrollContainer.dispatchEvent(
new WheelEvent("wheel", {
deltaY: -step,
bubbles: true,
cancelable: true,
}),
);
scrollContainer.scrollTop = Math.max(0, scrollContainer.scrollTop - step);
scrollContainer.dispatchEvent(new Event("scroll", { bubbles: true }));
}, delta);
remaining -= delta;
if ((await readScrollMetrics(page)).distanceFromBottom > NEAR_BOTTOM_THRESHOLD_PX) {
return;
}
}
}
export async function waitForAgentReady(page: Page, expectedTailText?: string): Promise<void> {
await expect(getVisibleChatScroll(page)).toBeVisible({ timeout: 60000 });
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible({
timeout: 60000,
});
await expect(page.getByTestId("agent-loading")).toHaveCount(0, { timeout: 60000 });
if (expectedTailText) {
await expect
.poll(async () => {
const metrics = await readScrollMetrics(page);
return metrics.contentHeight;
})
.toBeGreaterThan(0);
}
}
export async function expectNearBottom(page: Page): Promise<void> {
await expect
.poll(async () => {
@@ -252,15 +54,6 @@ export async function expectNearBottom(page: Page): Promise<void> {
.toBeLessThanOrEqual(NEAR_BOTTOM_THRESHOLD_PX);
}
export async function expectDetachedFromBottom(page: Page): Promise<void> {
await expect
.poll(async () => {
const metrics = await readScrollMetrics(page);
return metrics.distanceFromBottom;
})
.toBeGreaterThan(NEAR_BOTTOM_THRESHOLD_PX);
}
export async function waitForContentGrowth(
page: Page,
previousContentHeight: number,
@@ -273,11 +66,3 @@ export async function waitForContentGrowth(
.toBeGreaterThan(previousContentHeight);
return readScrollMetrics(page);
}
export async function getChatContainerKey(page: Page): Promise<string | null> {
return getVisibleChatScroll(page).evaluate((element) => {
const nativeId = (element as HTMLElement).id;
const prefix = "agent-chat-scroll-";
return nativeId.startsWith(prefix) ? nativeId.slice(prefix.length) : null;
});
}

View File

@@ -0,0 +1,35 @@
import { expect, type Page } from "@playwright/test";
import { readScrollMetrics, waitForContentGrowth, expectNearBottom } from "./agent-bottom-anchor";
export async function awaitAssistantMessage(page: Page, hasText?: string | RegExp): Promise<void> {
const messages = page.getByTestId("assistant-message");
const target = hasText === undefined ? messages.first() : messages.filter({ hasText }).first();
await expect(target).toBeVisible({ timeout: 30_000 });
}
export async function awaitToolCall(page: Page, toolName: string | RegExp): Promise<void> {
await expect(
page.getByTestId("tool-call-badge").filter({ hasText: toolName }).first(),
).toBeVisible({ timeout: 30_000 });
}
export async function expectAgentIdle(page: Page, timeout = 30_000): Promise<void> {
await expect(page.getByRole("button", { name: /stop|cancel/i })).toHaveCount(0, { timeout });
}
// The working indicator is an animated spinner View — no semantic ARIA role, testId is correct.
export async function expectInlineWorkingIndicator(page: Page): Promise<void> {
await expect(page.getByTestId("turn-working-indicator")).toBeVisible({ timeout: 30_000 });
}
export async function expectTurnCopyButton(page: Page): Promise<void> {
await expect(page.getByRole("button", { name: "Copy turn" }).first()).toBeVisible({
timeout: 30_000,
});
}
export async function expectScrollFollowsNewContent(page: Page): Promise<void> {
const { contentHeight } = await readScrollMetrics(page);
await waitForContentGrowth(page, contentHeight);
await expectNearBottom(page);
}

View File

@@ -1,217 +1,33 @@
import { expect, type Page } from "@playwright/test";
import { buildCreateAgentPreferences, buildSeededHost } from "./daemon-registry";
function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function getE2EDaemonPort(): string {
const port = process.env.E2E_DAEMON_PORT;
if (!port) {
throw new Error("E2E_DAEMON_PORT is not set (expected from Playwright globalSetup).");
}
if (port === "6767") {
throw new Error(
"E2E_DAEMON_PORT is 6767. Refusing to run e2e against the default local daemon.",
);
}
return port;
}
async function ensureE2EStorageSeeded(page: Page): Promise<void> {
const port = getE2EDaemonPort();
const expectedEndpoint = `127.0.0.1:${port}`;
const expectedServerId = process.env.E2E_SERVER_ID;
if (!expectedServerId) {
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
}
const needsReset = await page.evaluate(
({ expectedEndpoint: endpoint, expectedServerId: serverId }) => {
const raw = localStorage.getItem("@paseo:daemon-registry");
if (!raw) return true;
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed) || parsed.length !== 1) return true;
const entry = parsed[0] as { serverId?: string; connections?: unknown };
if (entry?.serverId !== serverId) return true;
const connections = entry?.connections;
if (!Array.isArray(connections)) return true;
if (
connections.some(
(c: { type?: string; endpoint?: string }) =>
c?.type === "directTcp" &&
typeof c?.endpoint === "string" &&
/:6767\b/.test(c.endpoint),
)
)
return true;
return !connections.some(
(c: { type?: string; endpoint?: string }) =>
c?.type === "directTcp" && c?.endpoint === endpoint,
);
} catch {
return true;
}
},
{ expectedEndpoint, expectedServerId },
);
if (!needsReset) {
return;
}
const nowIso = new Date().toISOString();
const daemon = buildSeededHost({
serverId: expectedServerId,
endpoint: expectedEndpoint,
nowIso,
});
const preferences = buildCreateAgentPreferences(expectedServerId);
await page.evaluate(
({ daemon: seededDaemon, preferences: seededPreferences }) => {
localStorage.setItem("@paseo:e2e", "1");
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededDaemon]));
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(seededPreferences));
localStorage.removeItem("@paseo:settings");
},
{ daemon, preferences },
);
await page.reload();
}
function parseRegistryEntry(registryRaw: string): { serverId: string; connections: unknown } {
let registry: unknown;
try {
registry = JSON.parse(registryRaw);
} catch {
throw new Error("E2E expected @paseo:daemon-registry to be valid JSON.");
}
if (!Array.isArray(registry) || registry.length !== 1) {
throw new Error(
`E2E expected @paseo:daemon-registry to contain exactly 1 daemon (got ${Array.isArray(registry) ? registry.length : "non-array"}).`,
);
}
const daemon = registry[0] as { serverId?: string; connections?: unknown };
if (typeof daemon?.serverId !== "string" || daemon.serverId.length === 0) {
throw new Error(
`E2E expected seeded daemon to have a string serverId (got ${String(daemon?.serverId)}).`,
);
}
return { serverId: daemon.serverId, connections: daemon.connections };
}
function assertDaemonConnections(connections: unknown, expectedEndpoint: string): void {
if (
!Array.isArray(connections) ||
!connections.some(
(c: { type?: string; endpoint?: string }) =>
c?.type === "directTcp" && c?.endpoint === expectedEndpoint,
)
) {
throw new Error(
`E2E expected seeded daemon connections to include directTcp ${expectedEndpoint} (got ${JSON.stringify(connections)}).`,
);
}
if (
Array.isArray(connections) &&
connections.some(
(c: { type?: string; endpoint?: string }) =>
c?.type === "directTcp" && typeof c?.endpoint === "string" && /:6767\b/.test(c.endpoint),
)
) {
throw new Error(
`E2E detected a daemon endpoint pointing at :6767 (${JSON.stringify(connections)}).`,
);
}
}
function assertPreferencesMatch(prefsRaw: string, serverId: string): void {
try {
const prefs = JSON.parse(prefsRaw) as { serverId?: string };
if (prefs?.serverId !== serverId) {
throw new Error(
`E2E expected create-agent-preferences.serverId to match seeded daemon serverId (${serverId}) (got ${String(prefs?.serverId)}).`,
);
}
} catch (error) {
if (error instanceof Error) throw error;
throw new Error("E2E expected @paseo:create-agent-preferences to be valid JSON.", {
cause: error,
});
}
}
async function assertE2EUsesSeededTestDaemon(page: Page): Promise<void> {
const port = getE2EDaemonPort();
const expectedEndpoint = `127.0.0.1:${port}`;
const expectedServerId = process.env.E2E_SERVER_ID;
if (!expectedServerId) {
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
}
const snapshot = await page.evaluate(() => {
const registryRaw = localStorage.getItem("@paseo:daemon-registry");
const prefsRaw = localStorage.getItem("@paseo:create-agent-preferences");
return { registryRaw, prefsRaw };
});
if (!snapshot.registryRaw) {
throw new Error("E2E expected @paseo:daemon-registry to be set before app load.");
}
const { serverId, connections } = parseRegistryEntry(snapshot.registryRaw);
if (serverId !== expectedServerId) {
throw new Error(
`E2E expected seeded daemon serverId to be ${expectedServerId} (got ${serverId}).`,
);
}
assertDaemonConnections(connections, expectedEndpoint);
if (!snapshot.prefsRaw) {
throw new Error("E2E expected @paseo:create-agent-preferences to be set before app load.");
}
assertPreferencesMatch(snapshot.prefsRaw, serverId);
}
import { escapeRegex } from "./regex";
export const gotoAppShell = async (page: Page) => {
await page.goto("/");
await ensureE2EStorageSeeded(page);
};
export const gotoHome = async (page: Page) => {
await gotoAppShell(page);
const composer = page.getByRole("textbox", { name: "Message agent..." });
if (
!(await composer
.first()
.isVisible()
.catch(() => false))
) {
const addProjectCta = page.getByText("Add a project", { exact: true }).first();
const addProjectSidebar = page.getByText("Add project", { exact: true }).first();
const newAgentButton = page.getByText("New agent", { exact: true }).first();
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
const entryButton = page
.getByText("Add a project", { exact: true })
.or(page.getByText("Add project", { exact: true }))
.or(page.getByText("New agent", { exact: true }))
.first();
await expect
.poll(
async () =>
(await addProjectCta.isVisible().catch(() => false)) ||
(await addProjectSidebar.isVisible().catch(() => false)) ||
(await newAgentButton.isVisible().catch(() => false)),
{ timeout: 10000 },
)
.toBe(true);
await expect
.poll(
async () =>
(await composer.isVisible().catch(() => false)) ||
(await entryButton.isVisible().catch(() => false)),
{ timeout: 10_000 },
)
.toBe(true);
if (await addProjectCta.isVisible().catch(() => false)) {
await addProjectCta.click();
} else if (await addProjectSidebar.isVisible().catch(() => false)) {
await addProjectSidebar.click();
} else {
await newAgentButton.click();
}
if (!(await composer.isVisible().catch(() => false))) {
await entryButton.click();
}
await expect(composer.first()).toBeVisible({ timeout: 30000 });
await expect(composer).toBeVisible({ timeout: 30_000 });
};
export const openSettings = async (page: Page) => {
@@ -341,46 +157,6 @@ export const setWorkingDirectory = async (page: Page, directory: string) => {
};
export const ensureHostSelected = async (page: Page) => {
await ensureE2EStorageSeeded(page);
// Absolute verification that we're using the per-run e2e daemon (never :6767).
// Also self-heal a rare case where app code rewrites daemon IDs after boot, by
// realigning create-agent-preferences.serverId to the sole seeded daemon.
try {
await assertE2EUsesSeededTestDaemon(page);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (!/create-agent-preferences\.serverId/i.test(message)) {
throw error;
}
const fix = await page.evaluate(() => {
const registryRaw = localStorage.getItem("@paseo:daemon-registry");
const prefsRaw = localStorage.getItem("@paseo:create-agent-preferences");
if (!registryRaw || !prefsRaw) return { ok: false, reason: "missing storage" } as const;
const registry = JSON.parse(registryRaw) as Array<{ serverId?: string }>;
const prefs = JSON.parse(prefsRaw) as { serverId?: string };
if (!Array.isArray(registry) || registry.length !== 1)
return { ok: false, reason: "registry shape" } as const;
const serverId = registry[0]?.serverId;
if (typeof serverId !== "string" || serverId.length === 0)
return { ok: false, reason: "missing serverId" } as const;
prefs.serverId = serverId;
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(prefs));
// Prevent the fixture's init-script from overwriting the corrected prefs on reload.
const nonce = localStorage.getItem("@paseo:e2e-seed-nonce") ?? "1";
localStorage.setItem("@paseo:e2e-disable-default-seed-once", nonce);
return { ok: true } as const;
});
if (!fix.ok) {
throw error;
}
await page.reload();
await assertE2EUsesSeededTestDaemon(page);
}
const input = page.getByRole("textbox", { name: "Message agent..." });
await expect(input).toBeVisible();
@@ -392,7 +168,7 @@ export const ensureHostSelected = async (page: Page) => {
if (await selectHostLabel.isVisible()) {
await selectHostLabel.click();
// E2E safety: we enforce a single seeded daemon, so the option should be unambiguous.
// We enforce a single seeded daemon, so the option should be unambiguous.
const localhostOption = page.getByText("localhost", { exact: true }).first();
const daemonIdOption = page
.getByText(process.env.E2E_SERVER_ID ?? "srv_e2e_test_daemon", { exact: true })
@@ -657,57 +433,3 @@ export const createAgentInRepo = async (
await setWorkingDirectory(page, config.directory);
await createAgent(page, config.prompt);
};
export const waitForPermissionPrompt = async (page: Page, timeout = 30000) => {
const promptText = page.getByTestId("permission-request-question").first();
await expect(promptText).toBeVisible({ timeout });
};
export const allowPermission = async (page: Page) => {
const acceptButton = page.getByTestId("permission-request-accept").first();
await expect(acceptButton).toBeVisible({ timeout: 5000 });
await acceptButton.click();
};
export const denyPermission = async (page: Page) => {
const denyButton = page.getByTestId("permission-request-deny").first();
await expect(denyButton).toBeVisible({ timeout: 5000 });
await denyButton.click();
};
export async function waitForAgentFinishUI(page: Page, timeout = 30000) {
// Wait for the stop button to disappear
const stopButton = page.getByRole("button", { name: /stop|cancel/i });
// First, let's debug what's happening - wait a bit to see the state
await page.waitForTimeout(2000);
// Check if stop button is visible
const isVisible = await stopButton.isVisible().catch(() => false);
if (isVisible) {
// If stop button is still visible after permission denial,
// it might be that the agent is waiting for something.
// Let's check if there's a tool call result or other UI indication
// Look for any indication that the agent has processed the permission denial
const toolCallResult = page.getByText(/permission.*denied|denied|blocked/i);
// Wait for the tool call result to appear
await expect(toolCallResult)
.toBeVisible({ timeout: 10000 })
.catch(() => {
// If no specific message, just wait for the button to disappear
});
// Now wait for the stop button to disappear
await expect(stopButton).not.toBeVisible({ timeout });
}
}
export async function getToolCallCount(page: Page): Promise<number> {
// Tool calls are rendered as ExpandableBadge components with tool names like Bash, Write, Read, etc.
// They appear as pressable badges in the agent stream
const toolCallBadges = page.locator('[data-testid="tool-call-badge"]');
return toolCallBadges.count();
}

View File

@@ -1,64 +1,21 @@
import { randomUUID } from "node:crypto";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { expect, type Page } from "@playwright/test";
import { buildCreateAgentPreferences, buildSeededHost } from "./daemon-registry";
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
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;
}
interface ArchiveTabDaemonClient {
connect(): Promise<void>;
close(): Promise<void>;
createAgent(options: {
provider: string;
model: string;
thinkingOptionId?: string;
modeId: string;
cwd: string;
title: string;
initialPrompt?: string;
}): Promise<{ id: string }>;
archiveAgent(agentId: string): Promise<{ archivedAt: string }>;
waitForFinish(agentId: string, timeout?: number): Promise<{ status: string }>;
waitForAgentUpsert(
agentId: string,
predicate: (snapshot: { status: string }) => boolean,
timeout?: number,
): Promise<{ status: string }>;
}
function getDaemonPort(): string {
const daemonPort = process.env.E2E_DAEMON_PORT;
if (!daemonPort) {
throw new Error("E2E_DAEMON_PORT is not set.");
}
if (daemonPort === "6767") {
throw new Error("E2E_DAEMON_PORT must not point at the developer daemon.");
}
return daemonPort;
}
function getServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
return serverId;
}
function getDaemonWsUrl(): string {
return `ws://127.0.0.1:${getDaemonPort()}/ws`;
workspaceId: string;
}
function buildSeededStoragePayload() {
@@ -66,55 +23,44 @@ function buildSeededStoragePayload() {
return {
daemon: buildSeededHost({
serverId: getServerId(),
endpoint: `127.0.0.1:${getDaemonPort()}`,
endpoint: `127.0.0.1:${getE2EDaemonPort()}`,
nowIso,
}),
preferences: buildCreateAgentPreferences(getServerId()),
};
}
interface ArchiveTabDaemonClientConfig {
url: string;
clientId: string;
clientType: "cli";
webSocketFactory?: NodeWebSocketFactory;
}
async function loadDaemonClientConstructor(): Promise<
new (config: ArchiveTabDaemonClientConfig) => ArchiveTabDaemonClient
> {
const repoRoot = path.resolve(__dirname, "../../../../");
const moduleUrl = pathToFileURL(
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
).href;
const mod = (await import(moduleUrl)) as {
DaemonClient: new (config: ArchiveTabDaemonClientConfig) => ArchiveTabDaemonClient;
};
return mod.DaemonClient;
}
export async function connectArchiveTabDaemonClient(): Promise<ArchiveTabDaemonClient> {
const DaemonClient = await loadDaemonClientConstructor();
const webSocketFactory = createNodeWebSocketFactory();
const client = new DaemonClient({
url: getDaemonWsUrl(),
clientId: `app-e2e-archive-tab-${randomUUID()}`,
clientType: "cli",
webSocketFactory,
});
await client.connect();
return client;
/**
* The slice of a daemon client `createIdleAgent` needs: spawn an agent and await
* its idle upsert. The shared seed client satisfies it, so a spec can seed an
* idle agent from the same client it uses for everything else.
*/
export interface IdleAgentSeedClient {
createAgent(options: {
provider: string;
model: string;
modeId: string;
cwd: string;
workspaceId: string;
title: string;
}): Promise<{ id: string }>;
waitForAgentUpsert(
agentId: string,
predicate: (snapshot: { status: string }) => boolean,
timeout?: number,
): Promise<{ status: string }>;
}
export async function createIdleAgent(
client: ArchiveTabDaemonClient,
input: { cwd: string; title: string },
client: IdleAgentSeedClient,
input: { cwd: string; workspaceId: string; title: string },
): Promise<ArchiveTabAgent> {
const created = await client.createAgent({
provider: "opencode",
model: "opencode/gpt-5-nano",
modeId: "bypassPermissions",
cwd: input.cwd,
workspaceId: input.workspaceId,
title: input.title,
});
const snapshot = await client.waitForAgentUpsert(
@@ -129,16 +75,33 @@ export async function createIdleAgent(
id: created.id,
title: input.title,
cwd: input.cwd,
workspaceId: input.workspaceId,
};
}
export async function archiveAgentFromDaemon(
client: ArchiveTabDaemonClient,
client: { archiveAgent(agentId: string): Promise<{ archivedAt: string }> },
agentId: string,
): Promise<void> {
await client.archiveAgent(agentId);
}
export async function fetchAgentArchivedAt(
client: {
fetchAgent(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 function getWorktreeRestoreFeature(client: {
getLastServerInfoMessage(): { features?: { worktreeRestore?: boolean } | null } | null;
}): boolean {
return client.getLastServerInfoMessage()?.features?.worktreeRestore === true;
}
export async function primeAdditionalPage(page: Page): Promise<void> {
const seedNonce = randomUUID();
const { daemon, preferences } = buildSeededStoragePayload();
@@ -191,7 +154,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.
@@ -257,16 +220,16 @@ 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,
});
}
const AGENT_ROW_SELECTOR = '[data-testid^="agent-row-"]';
function getSessionRowByTitle(page: Page, title: string) {
return page.locator('[data-testid^="agent-row-"]').filter({ hasText: title }).first();
return page.locator(AGENT_ROW_SELECTOR).filter({ hasText: title }).first();
}
export async function expectSessionRowVisible(page: Page, title: string): Promise<void> {
@@ -277,12 +240,24 @@ 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 });
await row.click();
}
export async function expectSessionsEmptyState(page: Page): Promise<void> {
// Guard: if session rows appear, a prior spec polluted the shared daemon — see 00-sessions-empty.spec.ts.
await expect(page.locator(AGENT_ROW_SELECTOR)).toHaveCount(0, { timeout: 5_000 });
await expect(page.getByText("No sessions yet")).toBeVisible({ timeout: 30_000 });
}
export async function archiveAgentFromSessions(
page: Page,
input: { agentId: string; title: string },

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,221 @@
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 { gotoAppShell } from "./app";
import { connectWorkspaceSetupClient } from "./workspace-setup";
import { selectWorkspaceInSidebar } from "./sidebar";
import { getServerId } from "./server-id";
import { waitForTabBar } from "./launcher";
function composerInput(page: Page) {
return page.getByRole("textbox", { name: "Message agent..." }).first();
}
export function composerLocator(page: Page) {
return composerInput(page);
}
export async function expectComposerVisible(
page: Page,
options?: { timeout?: number },
): Promise<void> {
await expect(composerInput(page)).toBeVisible({ timeout: options?.timeout ?? 15_000 });
}
export async function expectComposerDisabled(page: Page): Promise<void> {
// React Native TextInput with editable={false} renders as <textarea readonly> on web,
// not <textarea disabled>. Use not.toBeEditable() to match either form.
await expect(composerInput(page)).not.toBeEditable({ timeout: 10_000 });
}
export async function expectComposerDraft(page: Page, text: string): Promise<void> {
await expect(composerInput(page)).toHaveValue(text, { timeout: 5_000 });
}
export async function expectComposerEditable(page: Page): Promise<void> {
await expect(composerInput(page)).toBeEditable({ timeout: 15_000 });
}
export async function submitMessage(page: Page, text: string): Promise<void> {
const input = composerInput(page);
await expect(input).toBeEditable({ timeout: 30_000 });
await input.fill(text);
await input.press("Enter");
}
export async function fillComposerDraft(page: Page, text: string): Promise<void> {
await composerInput(page).fill(text);
}
export async function sendDraftToQueue(page: Page): Promise<void> {
await composerInput(page).press("Control+Enter");
}
export async function expectQueuedMessageButton(page: Page): Promise<void> {
await expect(page.getByRole("button", { name: "Send queued message now" })).toBeVisible({
timeout: 10_000,
});
}
export async function cancelAgent(page: Page): Promise<void> {
const stopButton = page.getByRole("button", { name: /stop|cancel/i }).first();
await expect(stopButton).toBeVisible({ timeout: 10_000 });
await stopButton.click();
}
/** Escape is bound to the "agent.interrupt" keyboard shortcut. */
export async function pressInterruptShortcut(page: Page): Promise<void> {
await page.keyboard.press("Escape");
}
export async function openAttachmentMenu(page: Page): Promise<void> {
await page.getByTestId("message-input-attach-button").filter({ visible: true }).first().click();
await expect(page.getByTestId("message-input-attachment-menu")).toBeVisible({ timeout: 5_000 });
}
export async function expectAttachButtonDisabled(page: Page): Promise<void> {
await expect(
page.getByTestId("message-input-attach-button").filter({ visible: true }).first(),
).toBeDisabled({ timeout: 10_000 });
}
export async function attachImageFromMenu(
page: Page,
file: { name: string; mimeType: string; buffer: Buffer },
): Promise<void> {
const chooserPromise = page.waitForEvent("filechooser", { timeout: 10_000 });
await openAttachmentMenu(page);
await page.getByTestId("message-input-attachment-menu-item-image").click();
const chooser = await chooserPromise;
await chooser.setFiles([file]);
}
export async function expectAttachmentPill(page: Page, testID: string): Promise<void> {
await expect(page.getByTestId(testID).first()).toBeVisible({ timeout: 10_000 });
}
/** Hover to reveal the X button (hidden until hover on desktop web), then click by accessible label. */
export async function removeAttachmentPill(
page: Page,
pillTestId: string,
removeAccessibilityLabel: string,
): Promise<void> {
await page.getByTestId(pillTestId).first().hover();
await page.getByRole("button", { name: removeAccessibilityLabel }).first().click();
}
export async function expectGithubAttachmentPill(
page: Page,
input: { number: number; title: string },
): Promise<void> {
const pill = page.getByTestId("composer-github-attachment-pill").first();
await expect(pill).toBeVisible({ timeout: 10_000 });
await expect(pill).toContainText(`#${input.number}`);
await expect(pill).toContainText(input.title);
}
export async function openImageLightbox(page: Page): Promise<void> {
await page.getByRole("button", { name: "Open image attachment" }).first().click();
await expect(page.getByTestId("attachment-lightbox-close")).toBeVisible({ timeout: 5_000 });
}
export async function closeImageLightbox(page: Page): Promise<void> {
await page.keyboard.press("Escape");
await expect(page.getByTestId("attachment-lightbox-close")).not.toBeVisible({ timeout: 5_000 });
}
export async function openGithubPickerFromMenu(page: Page): Promise<void> {
await openAttachmentMenu(page);
await page.getByTestId("message-input-attachment-menu-item-github").click();
await expect(page.getByTestId("combobox-desktop-container")).toBeVisible({ timeout: 5_000 });
}
/** Open picker, type a query, wait for the matching option by id (e.g. "issue:3", "pr:1"), and click it. */
export async function selectGithubOption(
page: Page,
searchTerm: string,
optionId: string,
): Promise<void> {
await openGithubPickerFromMenu(page);
const searchInput = page.getByPlaceholder("Search issues and PRs...");
await expect(searchInput).toBeVisible({ timeout: 5_000 });
await searchInput.fill(searchTerm);
const option = page.getByTestId(`composer-github-option-${optionId}`);
await expect(option).toBeVisible({ timeout: 15_000 });
await option.click();
}
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. */
export async function startRunningMockAgent(
page: Page,
opts: { prefix: string; model: string; prompt: string },
): Promise<MockAgentSetup> {
const serverId = getServerId();
const repo = await createTempGitRepo(opts.prefix);
const client = await connectSeedClient();
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,
});
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,
});
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 {
cleanup: () => Promise<void>;
}
/** Open a workspace backed by an existing repo path (e.g. a cloned GitHub repo). */
export async function openGithubWorkspace(
page: Page,
repoPath: string,
): Promise<GithubWorkspaceHandle> {
const client = await connectWorkspaceSetupClient();
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: async () => {
await client.removeProject(workspace.projectId).catch(() => undefined);
await client.close().catch(() => undefined);
},
};
}

View File

@@ -0,0 +1,65 @@
import { randomUUID } from "node:crypto";
import { readFileSync } from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { getE2EDaemonPort } from "./daemon-port";
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
export async function loadDaemonClientConstructor<ClientConfig, ClientInstance>(): Promise<
new (config: ClientConfig) => ClientInstance
> {
const repoRoot = path.resolve(__dirname, "../../../../");
const moduleUrl = pathToFileURL(
path.join(repoRoot, "packages/client/dist/daemon-client.js"),
).href;
const mod = (await import(moduleUrl)) as {
DaemonClient: new (config: ClientConfig) => ClientInstance;
};
return mod.DaemonClient;
}
interface E2EDaemonClientConfig {
url: string;
clientId: string;
clientType: "cli";
appVersion?: string;
webSocketFactory?: NodeWebSocketFactory;
}
function resolveDaemonWsUrl(): string {
return `ws://127.0.0.1:${getE2EDaemonPort()}/ws`;
}
export interface ConnectDaemonClientOptions {
clientIdPrefix: string;
appVersion?: string;
}
/**
* Connects an in-test daemon client over the isolated E2E daemon's WebSocket.
* The port-6767 guard keeps tests off the developer daemon. Each helper passes
* its own typed client interface as the generic.
*/
export async function connectDaemonClient<ClientInstance extends { connect(): Promise<void> }>(
options: ConnectDaemonClientOptions,
): Promise<ClientInstance> {
const DaemonClient = await loadDaemonClientConstructor<E2EDaemonClientConfig, ClientInstance>();
const client = new DaemonClient({
url: resolveDaemonWsUrl(),
clientId: `${options.clientIdPrefix}-${randomUUID()}`,
clientType: "cli",
appVersion: options.appVersion ?? loadAppVersion(),
webSocketFactory: createNodeWebSocketFactory(),
});
await client.connect();
return client;
}
function loadAppVersion(): string {
const packageJsonPath = path.resolve(__dirname, "../../package.json");
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { version?: unknown };
if (typeof packageJson.version !== "string" || packageJson.version.length === 0) {
throw new Error(`Missing app version in ${packageJsonPath}`);
}
return packageJson.version;
}

View File

@@ -0,0 +1,38 @@
import { escapeRegex } from "./regex";
/**
* Resolves the isolated E2E daemon's port, which Playwright's globalSetup
* publishes into the environment before any spec runs. Helpers and specs that
* build daemon WebSocket URLs, route patterns, or host endpoints share this
* accessor instead of re-reading the env var.
*
* The port-6767 guard is a hard guardrail: 6767 is the developer's default
* daemon, which manages real agents. The e2e port is never legitimately 6767,
* so refusing it here keeps every test off the developer daemon.
*/
export function getE2EDaemonPort(): string {
const port = process.env.E2E_DAEMON_PORT;
if (!port) {
throw new Error("E2E_DAEMON_PORT is not set (expected from Playwright globalSetup).");
}
if (port === "6767") {
throw new Error("E2E_DAEMON_PORT must not point at the developer daemon (6767).");
}
return port;
}
/**
* Playwright `routeWebSocket` matcher for a WebSocket on `port`. Matches the
* `:<port>` segment at a word boundary, so it catches the URL regardless of
* host or path. Use this when intercepting connections to an arbitrary port
* (e.g. blocking an unreachable test host); for the E2E daemon itself, prefer
* `daemonWsRoutePattern()`.
*/
export function wsRoutePatternForPort(port: string): RegExp {
return new RegExp(`:${escapeRegex(port)}\\b`);
}
/** `routeWebSocket` matcher for the isolated E2E daemon's WebSocket. */
export function daemonWsRoutePattern(): RegExp {
return wsRoutePatternForPort(getE2EDaemonPort());
}

View File

@@ -30,10 +30,15 @@ export function buildSeededHost(input: {
};
}
export const TEST_MOCK_PROVIDER_PREFERENCES = {
...TEST_PROVIDER_PREFERENCES,
mock: { model: "ten-second-stream" },
} as const;
export function buildCreateAgentPreferences(serverId: string) {
return {
serverId,
provider: "codex" as const,
providerPreferences: TEST_PROVIDER_PREFERENCES,
provider: "mock" as const,
providerPreferences: TEST_MOCK_PROVIDER_PREFERENCES,
};
}

View File

@@ -0,0 +1,157 @@
import { spawn, type ChildProcess } from "node:child_process";
import { createRequire } from "node:module";
import { readFile } from "node:fs/promises";
import net from "node:net";
import path from "node:path";
import { getE2EDaemonPort } from "./daemon-port";
/**
* Restarts the isolated E2E daemon against the SAME PASEO_HOME and SAME port so
* persisted state reloads and existing clients can reconnect. This exercises the
* post-restart rehydration path (the daemon rebuilding workspace/agent links
* from disk), which is where the worktree-branch regression lives.
*
* The daemon is owned by Playwright's `globalSetup`, which keeps its child
* handle in module scope we can't reach from a spec. Instead we drive it the
* same way an operator would: read the supervisor PID from
* `$PASEO_HOME/paseo.pid`, SIGTERM it (the supervisor forwards the signal to its
* worker and releases the lock), wait for the port to free, then re-spawn the
* supervisor with the identical environment globalSetup used. The relay and
* Metro processes are untouched, so we reuse their already-published ports.
*
* This NEVER targets the developer daemon: the port comes from
* `getE2EDaemonPort()`, which refuses 6767, and PASEO_HOME is the isolated E2E
* home globalSetup created.
*/
function getEnvOrThrow(name: string): string {
const value = process.env[name];
if (!value) {
throw new Error(`${name} is not set (expected from Playwright globalSetup).`);
}
return value;
}
async function readSupervisorPid(paseoHome: string): Promise<number> {
const pidPath = path.join(paseoHome, "paseo.pid");
const content = await readFile(pidPath, "utf8");
const parsed = JSON.parse(content) as { pid?: unknown };
if (typeof parsed.pid !== "number") {
throw new Error(`Malformed PID lock at ${pidPath}: ${content}`);
}
return parsed.pid;
}
function isPidRunning(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
function isPortListening(port: number, host = "127.0.0.1"): Promise<boolean> {
return new Promise((resolve) => {
const socket = net.connect(port, host, () => {
socket.end();
resolve(true);
});
socket.setTimeout(1000, () => {
socket.destroy();
resolve(false);
});
socket.on("error", () => resolve(false));
});
}
async function waitUntil(
predicate: () => Promise<boolean> | boolean,
options: { timeoutMs: number; label: string },
): Promise<void> {
const deadline = Date.now() + options.timeoutMs;
while (Date.now() < deadline) {
if (await predicate()) {
return;
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
throw new Error(`Timed out after ${options.timeoutMs}ms waiting for ${options.label}.`);
}
function spawnSupervisor(args: {
paseoHome: string;
port: string;
relayPort: string;
metroPort: string;
editorRecordPath: string;
}): ChildProcess {
const serverDir = path.resolve(__dirname, "../../../..", "packages/server");
// Run the supervisor through the resolved tsx CLI under the current node
// binary. Spawning the `node_modules/.bin/tsx` shim directly is unreliable
// inside the Playwright worker (the shim is a .mjs symlink, not an executable),
// so resolve the CLI module and load it with node.
const tsxCli = createRequire(path.join(serverDir, "package.json")).resolve("tsx/cli");
const child = spawn(process.execPath, [tsxCli, "scripts/supervisor-entrypoint.ts", "--dev"], {
cwd: serverDir,
env: {
...process.env,
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_NODE_ENV: "development",
NODE_ENV: "development",
},
stdio: ["ignore", "pipe", "pipe"],
detached: false,
});
child.stdout?.on("data", (data: Buffer) => {
for (const line of data.toString().split("\n")) {
if (line.trim()) console.log(`[daemon:restart] ${line.trim()}`);
}
});
child.stderr?.on("data", (data: Buffer) => {
for (const line of data.toString().split("\n")) {
if (line.trim()) console.error(`[daemon:restart] ${line.trim()}`);
}
});
// Detach our handles so the spawned supervisor outlives this spec process and
// is reaped by globalSetup's cleanup (the original process tree), not us.
child.unref();
return child;
}
export async function restartTestDaemon(): Promise<void> {
const port = getE2EDaemonPort();
const paseoHome = getEnvOrThrow("E2E_PASEO_HOME");
const relayPort = getEnvOrThrow("E2E_RELAY_PORT");
const metroPort = getEnvOrThrow("E2E_METRO_PORT");
const editorRecordPath =
process.env.E2E_EDITOR_RECORD_PATH ?? path.join(paseoHome, "editor-open-records.jsonl");
const pid = await readSupervisorPid(paseoHome);
process.kill(pid, "SIGTERM");
await waitUntil(() => !isPidRunning(pid), {
timeoutMs: 15_000,
label: `supervisor PID ${pid} to exit`,
});
await waitUntil(async () => !(await isPortListening(Number(port))), {
timeoutMs: 15_000,
label: `port ${port} to free`,
});
spawnSupervisor({ paseoHome, port, relayPort, metroPort, editorRecordPath });
await waitUntil(async () => isPortListening(Number(port)), {
timeoutMs: 30_000,
label: `restarted daemon to listen on port ${port}`,
});
}

View File

@@ -0,0 +1,352 @@
import { readFileSync } from "node:fs";
import { appendFile } from "node:fs/promises";
import { expect, type Page } from "@playwright/test";
import { openSettings } from "./app";
import { getE2EDaemonPort } from "./daemon-port";
import { openSettingsHost, openSettingsHostSection } from "./settings";
interface DaemonApiStatus {
version: string;
serverId: string;
hostname: string;
}
interface PidFileContent {
pid: number;
desktopManaged: boolean;
}
export interface RealDaemonState {
version: string;
pid: number | null;
logPath: string;
}
/**
* Reads live state from the running E2E test daemon: version from the HTTP
* status endpoint, PID from the paseo.pid lock file, log path from the
* E2E_PASEO_HOME directory. Call this in Node test code (not in the browser).
*/
export async function loadRealDaemonState(): Promise<RealDaemonState> {
const port = getE2EDaemonPort();
const paseoHome = process.env.E2E_PASEO_HOME;
if (!paseoHome) throw new Error("E2E_PASEO_HOME not set — globalSetup must run first");
const resp = await fetch(`http://127.0.0.1:${port}/api/status`);
const data: DaemonApiStatus = await resp.json();
let pid: number | null = null;
try {
const raw = readFileSync(`${paseoHome}/paseo.pid`, "utf8");
const pidContent: PidFileContent = JSON.parse(raw);
pid = pidContent.pid ?? null;
} catch (err) {
// PID file may not be present yet on a very fresh daemon start
console.warn("[desktop-updates] paseo.pid not found:", err);
}
return { version: data.version, pid, logPath: `${paseoHome}/daemon.log` };
}
export interface DesktopBridgeConfig {
serverId: string;
updateAvailable?: boolean;
latestVersion?: string;
slowInstall?: boolean;
/** Initial PID reported by desktop_daemon_status. Defaults to null. */
daemonPid?: number | null;
daemonVersion?: string | null;
daemonLogPath?: string;
/** Initial manageBuiltInDaemon setting. Defaults to false. */
manageBuiltInDaemon?: boolean;
/** Daemon listen address reported by desktop_daemon_status. Defaults to 127.0.0.1:6767. */
daemonListen?: string;
/** Keep start_desktop_daemon pending to hold the desktop startup blocker open. */
hangDaemonStart?: boolean;
/**
* Controls what dialog.ask returns when the daemon management confirm dialog
* fires. True = confirm (proceed with the action), false = cancel. Defaults to
* false so tests that only assert copy don't inadvertently trigger state changes.
*/
confirmShouldAccept?: boolean;
editorTargets?: DesktopEditorTargetConfig[];
editorRecordPath?: string;
}
interface DesktopEditorTargetConfig {
id: string;
label: string;
kind: "editor" | "file-manager";
}
interface DesktopEditorOpenRecord {
editorId: string;
path: string;
cwd?: string;
mode?: "open" | "reveal";
}
export interface ConfirmDialogCall {
message: string;
title: string | undefined;
}
declare global {
interface Window {
__capturedDialogCall: ConfirmDialogCall | undefined;
__recordDesktopEditorOpen?: (input: DesktopEditorOpenRecord) => Promise<void>;
}
}
/**
* Injects window.paseoDesktop before app load so all Electron-gated code
* activates. The update-check IPC is mocked at the boundary so the real
* auto-updater never fires. Daemon start/stop commands are stateful: the mock
* tracks running state and assigns a fresh PID on each start, letting tests
* observe PID changes without touching the real E2E daemon process.
* dialog.ask captures call arguments on window.__capturedDialogCall so tests
* can assert dialog copy without depending on window.confirm concatenation.
*/
export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfig): Promise<void> {
if (config.editorRecordPath) {
await page.exposeFunction(
"__recordDesktopEditorOpen",
async (input: DesktopEditorOpenRecord) => {
await appendFile(config.editorRecordPath as string, `${JSON.stringify(input)}\n`, "utf8");
},
);
}
await page.addInitScript((cfg) => {
// Mutable state shared across IPC calls within this page
let manageDaemon = cfg.manageBuiltInDaemon ?? false;
let daemonRunning = true;
let currentPid: number | null = cfg.daemonPid ?? null;
let startCount = 0;
function buildDaemonStatus() {
return {
serverId: cfg.serverId,
status: daemonRunning ? "running" : "stopped",
listen: cfg.daemonListen ?? "127.0.0.1:6767",
hostname: null,
pid: currentPid,
home: "",
version: cfg.daemonVersion ?? null,
desktopManaged: manageDaemon,
error: null,
};
}
function startDesktopDaemon() {
if (cfg.hangDaemonStart) {
return new Promise(() => undefined);
}
startCount += 1;
daemonRunning = true;
// First start (bootstrap) returns the configured PID; subsequent starts
// (after a stop) get a fresh PID so tests can observe the change.
currentPid = (cfg.daemonPid ?? 10000) + (startCount - 1) * 1000;
return buildDaemonStatus();
}
const desktopBridge: {
platform: string;
invoke: (command: string, args?: Record<string, unknown>) => Promise<unknown>;
dialog: {
ask: (message: string, options?: Record<string, unknown>) => Promise<boolean>;
};
getPendingOpenProject: () => Promise<string | null>;
events: { on: () => Promise<() => void> };
editor?: {
listTargets: () => Promise<DesktopEditorTargetConfig[]>;
openTarget: (input: DesktopEditorOpenRecord) => Promise<void>;
};
} = {
platform: "darwin",
invoke: async (command: string, args?: Record<string, unknown>) => {
if (command === "check_app_update") {
return cfg.updateAvailable
? {
hasUpdate: true,
readyToInstall: true,
currentVersion: "1.0.0",
latestVersion: cfg.latestVersion ?? "1.2.3",
body: null,
date: null,
}
: {
hasUpdate: false,
readyToInstall: false,
currentVersion: "1.0.0",
latestVersion: null,
body: null,
date: null,
};
}
if (command === "install_app_update") {
if (cfg.slowInstall) {
await new Promise<void>((resolve) => setTimeout(resolve, 3000));
}
return {
installed: true,
version: cfg.latestVersion ?? "1.2.3",
message: "App update installed. Restart required.",
};
}
if (command === "desktop_daemon_status") {
return buildDaemonStatus();
}
if (command === "desktop_daemon_logs") {
return { logPath: cfg.daemonLogPath ?? "", contents: "" };
}
if (command === "get_desktop_settings") {
return {
releaseChannel: "stable",
daemon: { manageBuiltInDaemon: manageDaemon, keepRunningAfterQuit: true },
};
}
if (command === "patch_desktop_settings") {
const daemon = args?.daemon;
if (
daemon !== null &&
typeof daemon === "object" &&
"manageBuiltInDaemon" in daemon &&
typeof daemon.manageBuiltInDaemon === "boolean"
) {
manageDaemon = daemon.manageBuiltInDaemon;
}
return {
releaseChannel: "stable",
daemon: { manageBuiltInDaemon: manageDaemon, keepRunningAfterQuit: true },
};
}
if (command === "stop_desktop_daemon") {
daemonRunning = false;
currentPid = null;
return buildDaemonStatus();
}
if (command === "start_desktop_daemon") {
return startDesktopDaemon();
}
return null;
},
dialog: {
ask: async (message: string, options?: Record<string, unknown>) => {
window.__capturedDialogCall = {
message,
title: typeof options?.title === "string" ? options.title : undefined,
};
return cfg.confirmShouldAccept ?? false;
},
},
getPendingOpenProject: async () => null,
events: { on: async () => () => undefined },
};
if (cfg.editorTargets) {
desktopBridge.editor = {
listTargets: async () => cfg.editorTargets ?? [],
openTarget: async (input: DesktopEditorOpenRecord) => {
await window.__recordDesktopEditorOpen?.(input);
},
};
}
(window as unknown as { paseoDesktop: unknown }).paseoDesktop = desktopBridge;
}, config);
}
export async function openDesktopSettings(page: Page, serverId: string): Promise<void> {
await openSettings(page);
await openSettingsHost(page, serverId);
// The daemon-lifecycle card moved to the Host section in the flat-settings
// layout; navigate there before asserting it.
await openSettingsHostSection(page, serverId, "host");
await expect(page.getByTestId("host-page-daemon-lifecycle-card")).toBeVisible({
timeout: 15_000,
});
}
export async function expectUpdateBanner(page: Page, version: string): Promise<void> {
const callout = page.getByTestId("update-callout");
await expect(callout).toBeVisible({ timeout: 15_000 });
await expect(callout).toContainText(`v${version.replace(/^v/i, "")}`);
}
export async function clickInstallUpdate(page: Page): Promise<void> {
await page.getByRole("button", { name: "Install & restart" }).click();
}
export async function expectInstallInProgress(page: Page): Promise<void> {
await expect(page.getByRole("button", { name: "Installing..." })).toBeVisible();
}
/**
* Clicks the daemon management switch and waits for dialog.ask to fire in the
* mock, then returns the captured call args (message + title). The mock auto-
* dismisses via confirmShouldAccept=false so callers can assert copy without
* worrying about state changes.
*/
export async function interceptDaemonManagementConfirmDialog(
page: Page,
): Promise<ConfirmDialogCall> {
await page.getByRole("switch", { name: "Manage built-in daemon" }).click();
await page.waitForFunction(() => !!window.__capturedDialogCall, { timeout: 5_000 });
return page.evaluate(() => window.__capturedDialogCall!);
}
export async function toggleDaemonManagement(
page: Page,
_action: "enable" | "disable",
): Promise<void> {
await page.getByRole("switch", { name: "Manage built-in daemon" }).click();
}
export function expectDaemonManagementConfirmDialog(args: ConfirmDialogCall): void {
expect(args.title).toBe("Pause built-in daemon");
expect(args.message).toContain("stop the built-in daemon immediately");
}
export async function expectDaemonManagementEnabled(page: Page): Promise<void> {
await expect(page.getByRole("switch", { name: "Manage built-in daemon" })).toBeChecked();
}
export async function expectDaemonManagementDisabled(page: Page): Promise<void> {
await expect(page.getByRole("switch", { name: "Manage built-in daemon" })).not.toBeChecked();
}
/**
* Asserts the daemon status card shows the given PID. Pass null to assert
* the cleared state (shown as "PID —" when the daemon is stopped).
*/
export async function expectDaemonStatusPid(page: Page, pid: number | null): Promise<void> {
const expected = pid !== null ? `PID ${pid}` : "PID —";
await expect(
page.getByTestId("host-page-daemon-lifecycle-card").getByText(expected),
).toBeVisible();
}
export async function expectDaemonStatusLogPath(page: Page, logPath: string): Promise<void> {
await expect(
page.getByTestId("host-page-daemon-lifecycle-card").getByText(logPath),
).toBeVisible();
}
/**
* Asserts the host page identity badge shows the given version string.
* The badge is populated from the live WebSocket session's serverInfo.version.
*/
export async function expectDaemonStatusVersion(page: Page, version: string): Promise<void> {
await expect(
page.getByTestId("host-page-identity").getByText(version, { exact: false }),
).toBeVisible({ timeout: 15_000 });
}

View File

@@ -0,0 +1,283 @@
import { execFileSync, execSync } from "node:child_process";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import path from "node:path";
export function hasGithubAuth(): boolean {
try {
execSync("gh auth status", { stdio: "ignore" });
return true;
} catch {
return false;
}
}
export interface CheckSpec {
context: string;
state: "success" | "failure" | "pending";
}
export interface PrSpec {
title: string;
state: "open" | "merged" | "closed" | "draft";
checks?: CheckSpec[];
commentCount?: number;
}
export interface IssueSpec {
title: string;
body?: string;
labels?: string[];
state?: "open" | "closed";
}
export interface GhPrFixture {
number: number;
title: string;
url: string;
branch: string;
localPath: string;
}
export interface GhIssueFixture {
number: number;
title: string;
url: string;
}
export interface GhRepoFixture {
owner: string;
name: string;
fullName: string;
defaultBranch: string;
prs: GhPrFixture[];
issues: GhIssueFixture[];
cleanup(): Promise<void>;
}
export interface GhDefaultBranchClone {
path: string;
cleanup(): Promise<void>;
}
function gh(args: string[], opts?: { cwd?: string }): string {
return execFileSync("gh", args, {
cwd: opts?.cwd,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
}).trim();
}
function git(args: string[], cwd: string): string {
return execFileSync("git", args, {
cwd,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
}).trim();
}
async function seedPr(args: {
spec: PrSpec;
branch: string;
index: number;
basePath: string;
authedUrl: string;
fullName: string;
repoName: string;
defaultBranch: string;
}): Promise<{ fixture: GhPrFixture; localPath: string }> {
const { spec, branch, index, basePath, authedUrl, fullName, repoName, defaultBranch } = args;
const createArgs = [
"pr",
"create",
"--title",
spec.title,
"--base",
defaultBranch,
"--head",
branch,
"--body",
"",
];
if (spec.state === "draft") createArgs.push("--draft");
const prUrl = gh(createArgs, { cwd: basePath });
const prNumber = parseInt(prUrl.split("/").pop() ?? "0", 10);
if (spec.checks && spec.checks.length > 0) {
const sha = git(["rev-parse", branch], basePath);
for (const check of spec.checks) {
gh([
"api",
`repos/${fullName}/statuses/${sha}`,
"--method",
"POST",
"-f",
`state=${check.state}`,
"-f",
`context=${check.context}`,
"-f",
`target_url=https://example.com/${encodeURIComponent(check.context)}`,
]);
}
}
for (let j = 0; j < (spec.commentCount ?? 0); j++) {
gh(["pr", "comment", String(prNumber), "--body", `Test comment ${j + 1}`], { cwd: basePath });
}
if (spec.state === "merged") {
gh(["pr", "merge", String(prNumber), "--merge"], { cwd: basePath });
} else if (spec.state === "closed") {
gh(["pr", "close", String(prNumber)], { cwd: basePath });
}
const localPath = await mkdtemp(path.join("/tmp", `${repoName}-ws-${index}-`));
git(["clone", authedUrl, localPath, "--quiet", "-b", branch], basePath);
// Clean remote URL (no embedded token) so gh can parse owner/repo
git(["remote", "set-url", "origin", `https://github.com/${fullName}.git`], localPath);
git(["config", "user.email", "e2e@paseo.test"], localPath);
git(["config", "user.name", "Paseo E2E"], localPath);
git(["config", "commit.gpgsign", "false"], localPath);
return {
fixture: { number: prNumber, title: spec.title, url: prUrl, branch, localPath },
localPath,
};
}
function seedIssue(args: { spec: IssueSpec; basePath: string }): GhIssueFixture {
const { spec, basePath } = args;
const createArgs = ["issue", "create", "--title", spec.title, "--body", spec.body ?? ""];
for (const label of spec.labels ?? []) {
createArgs.push("--label", label);
}
const issueUrl = gh(createArgs, { cwd: basePath });
const issueNumber = parseInt(issueUrl.split("/").pop() ?? "0", 10);
if (spec.state === "closed") {
gh(["issue", "close", String(issueNumber)], { cwd: basePath });
}
return { number: issueNumber, title: spec.title, url: issueUrl };
}
// Single namespace for temporary GitHub repos created by Paseo tests.
// Bulk cleanup relies on this prefix being unmistakable — never reuse `paseo-`
// (collides with real repos like `paseo`, `paseo-website`).
const TEMP_GITHUB_REPO_PREFIX = "paseotmp-";
export async function createTempGithubRepo(options: {
category: string;
prs?: PrSpec[];
issues?: IssueSpec[];
}): Promise<GhRepoFixture> {
const { category, prs = [], issues = [] } = options;
const uniqueSuffix = `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
const repoName = `${TEMP_GITHUB_REPO_PREFIX}${category}-${uniqueSuffix}`;
const defaultBranch = "main";
// Bootstrap local git repo
const basePath = await mkdtemp(path.join("/tmp", `${repoName}-base-`));
git(["init", "-b", defaultBranch], basePath);
git(["config", "user.email", "e2e@paseo.test"], basePath);
git(["config", "user.name", "Paseo E2E"], basePath);
git(["config", "commit.gpgsign", "false"], basePath);
await writeFile(path.join(basePath, "README.md"), "# E2E Test Repo\n");
git(["add", "README.md"], basePath);
git(["commit", "-m", "Initial commit"], basePath);
// Create GitHub repo and push initial commit
gh(["repo", "create", repoName, "--private", `--source=${basePath}`, "--push"]);
const owner = gh(["api", "user", "--jq", ".login"]);
const fullName = `${owner}/${repoName}`;
const token = gh(["auth", "token"]);
const authedUrl = `https://x-access-token:${token}@github.com/${fullName}.git`;
// Switch remote to authed URL for subsequent pushes
git(["remote", "set-url", "origin", authedUrl], basePath);
// Create a branch + commit for each PR spec
const branches: string[] = [];
for (let i = 0; i < prs.length; i++) {
const branch = `pr-branch-${i + 1}`;
branches.push(branch);
git(["checkout", "-b", branch], basePath);
await writeFile(path.join(basePath, `pr-${i + 1}.txt`), `PR ${i + 1}\n`);
git(["add", `pr-${i + 1}.txt`], basePath);
git(["commit", "-m", `Add PR ${i + 1}`], basePath);
git(["checkout", defaultBranch], basePath);
}
if (branches.length > 0) {
git(["push", "origin", ...branches], basePath);
}
// Create PRs, seed checks/comments, apply state changes, clone workspaces
const prFixtures: GhPrFixture[] = [];
const localPaths: string[] = [];
for (let i = 0; i < prs.length; i++) {
const { fixture, localPath } = await seedPr({
spec: prs[i],
branch: branches[i],
index: i,
basePath,
authedUrl,
fullName,
repoName,
defaultBranch,
});
localPaths.push(localPath);
prFixtures.push(fixture);
}
// Create issues
const issueFixtures: GhIssueFixture[] = [];
for (const spec of issues) {
issueFixtures.push(seedIssue({ spec, basePath }));
}
return {
owner,
name: repoName,
fullName,
defaultBranch,
prs: prFixtures,
issues: issueFixtures,
cleanup: async () => {
try {
gh(["repo", "delete", fullName, "--yes"]);
} catch {
// Best-effort cleanup
}
await Promise.all([
rm(basePath, { recursive: true, force: true }),
...localPaths.map((p) => rm(p, { recursive: true, force: true })),
]);
},
};
}
export async function cloneGithubRepoDefaultBranchOnly(
repo: Pick<GhRepoFixture, "fullName" | "name" | "defaultBranch">,
): Promise<GhDefaultBranchClone> {
const token = gh(["auth", "token"]);
const authedUrl = `https://x-access-token:${token}@github.com/${repo.fullName}.git`;
const clonePath = await mkdtemp(path.join("/tmp", `${repo.name}-${repo.defaultBranch}-only-`));
execFileSync(
"git",
["clone", "--quiet", "--single-branch", "--branch", repo.defaultBranch, authedUrl, clonePath],
{ stdio: ["ignore", "pipe", "pipe"] },
);
git(["config", "user.email", "e2e@paseo.test"], clonePath);
git(["config", "user.name", "Paseo E2E"], clonePath);
git(["config", "commit.gpgsign", "false"], clonePath);
return {
path: clonePath,
cleanup: async () => {
await rm(clonePath, { recursive: true, force: true });
},
};
}

View File

@@ -1,20 +1,13 @@
import { expect, type Page } from "@playwright/test";
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
import { createTempGitRepo } from "./workspace";
import { getServerId } from "./server-id";
// ─── Navigation ────────────────────────────────────────────────────────────
function getServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
}
return serverId;
}
/** Navigate to a workspace and wait for the tab bar to appear. */
export async function gotoWorkspace(page: Page, cwd: string): Promise<void> {
const route = buildHostWorkspaceRoute(getServerId(), cwd);
export async function gotoWorkspace(page: Page, workspaceId: string): Promise<void> {
const route = buildHostWorkspaceRoute(getServerId(), workspaceId);
await page.goto(route);
await waitForTabBar(page);
}
@@ -68,20 +61,6 @@ export async function getActiveTabTestId(page: Page): Promise<string | null> {
// ─── Tab actions ───────────────────────────────────────────────────────────
/** Click the new agent tab button in the tab bar. Creates a draft/chat tab directly. */
export async function clickNewTabButton(page: Page): Promise<void> {
const button = page.getByTestId("workspace-new-agent-tab").filter({ visible: true }).first();
await expect(button).toBeVisible({ timeout: 10_000 });
await button.click();
}
/** Click the new terminal button in the workspace tab bar. Creates a terminal tab directly. */
export async function clickNewTerminalButton(page: Page): Promise<void> {
const button = page.getByTestId("workspace-new-terminal").filter({ visible: true }).first();
await expect(button).toBeVisible({ timeout: 10_000 });
await button.click();
}
/** Press Cmd+T (macOS) or Ctrl+T (Linux/Windows) to open a new tab. */
export async function pressNewTabShortcut(page: Page): Promise<void> {
const modifier = process.platform === "darwin" ? "Meta" : "Control";
@@ -90,39 +69,46 @@ export async function pressNewTabShortcut(page: Page): Promise<void> {
// ─── Tab bar assertions ───────────────────────────────────────────────────
/** @deprecated The launcher panel was removed. Actions go directly to their target. */
export async function waitForLauncherPanel(_page: Page): Promise<void> {
// No-op: the launcher panel no longer exists.
}
/** Assert the new agent tab button is visible in the tab bar. */
/** Assert the inline new-agent plus button is visible in the tab bar. */
export async function assertNewChatTileVisible(page: Page): Promise<void> {
await expect(
page.getByTestId("workspace-new-agent-tab").filter({ visible: true }).first(),
page.getByTestId("workspace-new-agent-tab-inline").filter({ visible: true }).first(),
).toBeVisible();
}
/** Assert the new terminal button is visible in the tab bar. */
export async function assertTerminalTileVisible(page: Page): Promise<void> {
/** Assert the new-tab dropdown trigger is visible in the tab bar. */
export async function assertNewTabMenuTriggerVisible(page: Page): Promise<void> {
await expect(
page.getByTestId("workspace-new-terminal").filter({ visible: true }).first(),
page.getByTestId("workspace-new-tab-menu-trigger").filter({ visible: true }).first(),
).toBeVisible();
}
// ─── Tab creation actions ─────────────────────────────────────────────────
/** Click the new agent tab button to create a draft/chat tab. */
/** Click the inline plus button to create a draft/chat tab. */
export async function clickNewChat(page: Page): Promise<void> {
const button = page.getByTestId("workspace-new-agent-tab").filter({ visible: true }).first();
const button = page
.getByTestId("workspace-new-agent-tab-inline")
.filter({ visible: true })
.first();
await expect(button).toBeVisible({ timeout: 10_000 });
await button.click();
}
/** Click the new terminal button to create a terminal tab. */
export async function clickTerminal(page: Page): Promise<void> {
const button = page.getByTestId("workspace-new-terminal").filter({ visible: true }).first();
await expect(button).toBeVisible({ timeout: 10_000 });
await button.click();
/** Open the new-tab menu and click "New terminal". */
export async function clickNewTerminal(page: Page): Promise<void> {
const trigger = page
.getByTestId("workspace-new-tab-menu-trigger")
.filter({ visible: true })
.first();
await expect(trigger).toBeVisible({ timeout: 10_000 });
await trigger.click();
const item = page
.getByTestId("workspace-new-tab-menu-terminal")
.filter({ visible: true })
.first();
await expect(item).toBeVisible({ timeout: 10_000 });
await item.click();
}
// ─── Tab title assertions ──────────────────────────────────────────────────
@@ -143,9 +129,9 @@ export async function waitForTabWithTitle(
).toBeVisible({ timeout });
}
/** Assert the new agent tab button is visible in the tab bar. */
/** Assert the inline new-agent plus button is visible in the tab bar. */
export async function assertSingleNewTabButton(page: Page): Promise<void> {
const buttons = page.getByTestId("workspace-new-agent-tab").filter({ visible: true });
const buttons = page.getByTestId("workspace-new-agent-tab-inline").filter({ visible: true });
const count = await buttons.count();
expect(count).toBeGreaterThanOrEqual(1);
}
@@ -193,6 +179,19 @@ export async function sampleTabsDuringTransition(
return snapshots;
}
export function terminalSurfaceLocator(page: Page) {
return page.locator('[data-testid="terminal-surface"]').first();
}
export async function expectAgentTabActive(page: Page, agentId: string): Promise<void> {
const tabTestId = `workspace-tab-agent_${agentId}`;
await expect(page.getByTestId(tabTestId).filter({ visible: true })).toHaveAttribute(
"aria-selected",
"true",
);
await expect(getActiveTabTestId(page)).resolves.toBe(tabTestId);
}
// ─── Workspace setup ───────────────────────────────────────────────────────
/** Create a temp git repo and return its path with a cleanup function. */

View File

@@ -0,0 +1,72 @@
import type { Page } from "@playwright/test";
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
import { seedWorkspace, type SeedDaemonClient } from "./seed-client";
import { getServerId } from "./server-id";
export interface MockAgentWorkspace {
agentId: string;
workspaceId: string;
cwd: string;
client: SeedDaemonClient;
cleanup(): Promise<void>;
}
export interface MockAgentOptions {
repoPrefix: string;
title: string;
initialPrompt?: string;
model?: string;
modeId?: string;
featureValues?: Record<string, unknown>;
}
/**
* Seeds a temp git repo, opens it as a project, and creates a ready mock-provider
* agent in it via the daemon. Returns the agent id plus a cleanup that closes the
* client and removes the repo. Pair with {@link openAgentRoute} to drive the UI.
*/
export async function seedMockAgentWorkspace(
options: MockAgentOptions,
): Promise<MockAgentWorkspace> {
const workspace = await seedWorkspace({ repoPrefix: options.repoPrefix });
try {
const agent = await workspace.client.createAgent({
provider: "mock",
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: options.title,
modeId: options.modeId ?? "load-test",
model: options.model ?? "ten-second-stream",
initialPrompt: options.initialPrompt,
featureValues: options.featureValues,
});
return {
agentId: agent.id,
workspaceId: workspace.workspaceId,
cwd: workspace.repoPath,
client: workspace.client,
cleanup: workspace.cleanup,
};
} catch (error) {
await workspace.cleanup();
throw error;
}
}
export function buildAgentRoute(workspaceId: string, agentId: string): string {
return `${buildHostWorkspaceRoute(getServerId(), workspaceId)}?open=${encodeURIComponent(
`agent:${agentId}`,
)}`;
}
/** Boots the app directly at the agent's workspace route and waits for the open intent to settle. */
export async function openAgentRoute(
page: Page,
input: { workspaceId: string; agentId: string },
): Promise<void> {
await page.goto(buildAgentRoute(input.workspaceId, input.agentId));
await page.waitForURL(
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
{ timeout: 60_000 },
);
}

View File

@@ -1,76 +1,81 @@
import { randomUUID } from "node:crypto";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { expect, type Page } from "@playwright/test";
import type { DaemonClient as ServerDaemonClient } from "@server/client/daemon-client";
import type { DaemonClient as InternalDaemonClient } from "@getpaseo/client/internal/daemon-client";
import { decodeWorkspaceIdFromPathSegment } from "@/utils/host-routes";
import { expectWorkspaceHeader, workspaceLabelFromPath } from "./workspace-ui";
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
import { connectDaemonClient } from "./daemon-client-loader";
import { daemonWsRoutePattern } from "./daemon-port";
import { expectWorkspaceHeader } from "./workspace-ui";
type NewWorkspaceDaemonClient = Pick<
ServerDaemonClient,
InternalDaemonClient,
| "archivePaseoWorktree"
| "archiveWorkspace"
| "checkoutRefresh"
| "close"
| "connect"
| "createPaseoWorktree"
| "openProject"
| "createWorkspace"
| "fetchWorkspaces"
| "getPaseoWorktreeList"
| "getDaemonConfig"
| "patchDaemonConfig"
| "removeProject"
>;
interface NewWorkspaceDaemonClientConfig {
url: string;
clientId: string;
clientType: "cli";
webSocketFactory?: NodeWebSocketFactory;
}
type OpenProjectPayload = Awaited<ReturnType<NewWorkspaceDaemonClient["openProject"]>>;
type CreateWorkspacePayload = Awaited<ReturnType<NewWorkspaceDaemonClient["createWorkspace"]>>;
type WorkspacePayload = Pick<CreateWorkspacePayload, "error" | "workspace">;
type WorkspaceDescriptor = NonNullable<CreateWorkspacePayload["workspace"]>;
export interface OpenedProject {
workspaceId: string;
projectKey: string;
projectDisplayName: string;
workspaceName: string;
workspaceDirectory: string;
}
function getDaemonPort(): string {
const daemonPort = process.env.E2E_DAEMON_PORT;
if (!daemonPort) {
throw new Error("E2E_DAEMON_PORT is not set.");
}
if (daemonPort === "6767") {
throw new Error("E2E_DAEMON_PORT must not point at the developer daemon.");
}
return daemonPort;
}
function getDaemonWsUrl(): string {
return `ws://127.0.0.1:${getDaemonPort()}/ws`;
}
async function loadDaemonClientConstructor(): Promise<
new (config: NewWorkspaceDaemonClientConfig) => NewWorkspaceDaemonClient
> {
const repoRoot = path.resolve(__dirname, "../../../../");
const moduleUrl = pathToFileURL(
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
).href;
const mod = (await import(moduleUrl)) as {
DaemonClient: new (config: NewWorkspaceDaemonClientConfig) => NewWorkspaceDaemonClient;
};
return mod.DaemonClient;
}
function requireWorkspace(payload: OpenProjectPayload) {
function requireWorkspace(payload: WorkspacePayload) {
if (payload.error) {
throw new Error(payload.error);
}
if (!payload.workspace) {
throw new Error("openProject returned no workspace.");
throw new Error("workspace.create returned no workspace.");
}
return payload.workspace;
}
function openedProjectFromWorkspace(workspace: WorkspaceDescriptor): OpenedProject {
return {
workspaceId: workspace.id,
projectKey: workspace.projectId,
projectDisplayName: workspace.projectDisplayName,
workspaceName: workspace.name,
workspaceDirectory: workspace.workspaceDirectory,
};
}
async function fetchWorkspaceById(
client: NewWorkspaceDaemonClient,
workspaceId: string,
): Promise<WorkspaceDescriptor | null> {
const payload = await client.fetchWorkspaces();
return payload.entries.find((entry) => entry.id === workspaceId) ?? null;
}
async function waitForWorkspaceDescriptor(
client: NewWorkspaceDaemonClient,
workspaceId: string,
): Promise<WorkspaceDescriptor> {
const deadline = Date.now() + 30_000;
while (Date.now() < deadline) {
const workspace = await fetchWorkspaceById(client, workspaceId);
if (workspace) {
return workspace;
}
await new Promise((resolve) => setTimeout(resolve, 250));
}
throw new Error(`Workspace descriptor not found: ${workspaceId}`);
}
function parseWorkspaceIdFromPageUrl(page: Page, serverId: string): string | null {
const pathname = new URL(page.url()).pathname;
const match = pathname.match(
@@ -83,41 +88,37 @@ function parseWorkspaceIdFromPageUrl(page: Page, serverId: string): string | nul
}
export async function connectNewWorkspaceDaemonClient(): Promise<NewWorkspaceDaemonClient> {
const DaemonClient = await loadDaemonClientConstructor();
const webSocketFactory = createNodeWebSocketFactory();
const client = new DaemonClient({
url: getDaemonWsUrl(),
clientId: `app-e2e-new-workspace-${randomUUID()}`,
clientType: "cli",
webSocketFactory,
return connectDaemonClient<NewWorkspaceDaemonClient>({
clientIdPrefix: "app-e2e-new-workspace",
});
await client.connect();
return client;
}
export async function openProjectViaDaemon(
client: NewWorkspaceDaemonClient,
repoPath: string,
): Promise<OpenedProject> {
const workspace = requireWorkspace(await client.openProject(repoPath));
return {
workspaceId: workspace.id,
projectKey: workspace.projectId,
projectDisplayName: workspace.projectDisplayName,
workspaceName: workspace.name,
};
const workspace = requireWorkspace(
await client.createWorkspace({
source: { kind: "directory", path: repoPath },
}),
);
return openedProjectFromWorkspace(workspace);
}
export async function archiveWorkspaceFromDaemon(
client: NewWorkspaceDaemonClient,
workspaceId: string,
workspaceDirectory: string,
options?: { scope?: "workspace" | "worktree" },
): Promise<void> {
const payload = await client.archivePaseoWorktree({ worktreePath: workspaceId });
const payload = await client.archivePaseoWorktree({
worktreePath: workspaceDirectory,
...(options?.scope !== undefined ? { scope: options.scope } : {}),
});
if (payload.error) {
throw new Error(payload.error.message);
}
if (!payload.success) {
throw new Error(`Failed to archive workspace: ${workspaceId}`);
throw new Error(`Failed to archive workspace: ${workspaceDirectory}`);
}
}
@@ -143,12 +144,7 @@ export async function createWorktreeViaDaemon(
worktreeSlug: input.slug,
});
const workspace = requireWorkspace(payload);
return {
workspaceId: workspace.id,
projectKey: workspace.projectId,
projectDisplayName: workspace.projectDisplayName,
workspaceName: workspace.name,
};
return openedProjectFromWorkspace(workspace);
}
export async function openNewWorkspaceComposer(
@@ -168,11 +164,93 @@ export async function openNewWorkspaceComposer(
});
}
export async function openGlobalNewWorkspaceComposer(page: Page): Promise<void> {
await page.getByTestId("sidebar-global-new-workspace").click();
await expect(page).toHaveURL(/\/h\/[^/]+\/new(?:\?.*)?$/, {
timeout: 30_000,
});
}
export async function expectNewWorkspaceProjectSelected(
page: Page,
projectDisplayName: string,
): Promise<void> {
const projectPicker = page.getByRole("button", { name: "Workspace project" });
await expect(projectPicker).toBeVisible({ timeout: 30_000 });
await expect(projectPicker).toContainText(projectDisplayName);
}
export async function submitNewWorkspacePrompt(
page: Page,
prompt = "Hello from e2e",
): Promise<void> {
const composer = page.getByRole("textbox", { name: "Message agent..." });
await expect(composer).toBeVisible({ timeout: 30_000 });
await composer.fill(prompt);
const createButton = page
.getByTestId("message-input-root")
.getByRole("button", { name: "Create" });
await expect(createButton).toBeVisible({ timeout: 30_000 });
await createButton.click();
}
export async function clickNewWorkspaceButton(
page: Page,
input: { projectKey: string; projectDisplayName: string; prompt?: string },
): Promise<void> {
await openNewWorkspaceComposer(page, input);
await submitNewWorkspacePrompt(page, input.prompt);
}
export async function selectNewWorkspaceProject(
page: Page,
input: { projectKey: string; projectDisplayName: string },
): Promise<void> {
await openNewWorkspaceComposer(page, input);
const trigger = page.getByTestId("new-workspace-project-picker-trigger");
await expect(trigger).toBeVisible({ timeout: 30_000 });
await trigger.click();
const option = page.getByTestId(`new-workspace-project-picker-option-${input.projectKey}`);
await expect(option).toBeVisible({ timeout: 30_000 });
await option.click();
await expectNewWorkspaceProjectSelected(page, input.projectDisplayName);
}
// The isolation trigger renders the active isolation's label ("Local" / "New
// worktree"), so asserting its text proves what the screen currently remembers.
const ISOLATION_TRIGGER_LABEL: Record<"local" | "worktree", string> = {
local: "Local",
worktree: "New worktree",
};
export async function expectWorkspaceIsolationSelected(
page: Page,
isolation: "local" | "worktree",
): Promise<void> {
const trigger = page.getByRole("button", { name: "Workspace isolation" });
await expect(trigger).toBeVisible({ timeout: 30_000 });
await expect(trigger).toContainText(ISOLATION_TRIGGER_LABEL[isolation]);
}
export async function selectWorkspaceIsolation(
page: Page,
isolation: "local" | "worktree",
): Promise<void> {
const trigger = page.getByTestId("workspace-create-isolation-trigger");
await expect(trigger).toBeVisible({ timeout: 30_000 });
await trigger.click();
// "New worktree" is only listed once the checkout status query confirms the
// selected project is a git repo, so wait for the option to appear before
// clicking it.
const option = page.getByTestId(`workspace-create-isolation-${isolation}`);
await expect(option).toBeVisible({ timeout: 30_000 });
await option.click();
}
export async function submitNewWorkspaceEmpty(page: Page): Promise<void> {
const createButton = page
.getByTestId("message-input-root")
.getByRole("button", { name: "Create" });
@@ -202,12 +280,47 @@ export async function expectStartingRefPickerTriggerPr(
page: Page,
input: { number: number; title: string; headRef: string },
): Promise<void> {
const trigger = page.getByTestId("new-workspace-ref-picker-trigger");
const trigger = page.getByRole("button", { name: "Starting ref" });
await expect(trigger).toContainText(`#${input.number}`);
await expect(trigger).toContainText(input.title);
await expect(trigger).not.toContainText(input.headRef);
}
export async function openBranchPicker(page: Page): Promise<void> {
const trigger = page.getByRole("button", { name: "Starting ref" });
await expect(trigger).toBeVisible({ timeout: 30_000 });
await trigger.click();
}
export async function selectPickerOptionByKeyboard(page: Page, label: string): Promise<void> {
const searchInput = page.getByPlaceholder("Search branches and PRs");
await expect(searchInput).toBeVisible({ timeout: 30_000 });
await page.keyboard.type(label);
await expect(page.getByTestId(`new-workspace-ref-picker-branch-${label}`)).toBeVisible({
timeout: 10_000,
});
await page.keyboard.press("Enter");
}
export async function closeBranchPicker(page: Page): Promise<void> {
await page.keyboard.press("Escape");
}
export async function expectPickerOpen(page: Page): Promise<void> {
await expect(page.getByTestId("combobox-desktop-container")).toBeVisible({ timeout: 30_000 });
}
export async function expectPickerClosed(page: Page): Promise<void> {
await expect(page.getByTestId("combobox-desktop-container")).not.toBeVisible({
timeout: 30_000,
});
}
export async function expectPickerSelected(page: Page, label: string): Promise<void> {
const trigger = page.getByRole("button", { name: "Starting ref" });
await expect(trigger).toContainText(label);
}
export async function expectComposerGithubAttachmentPill(
page: Page,
input: { number: number; title: string },
@@ -220,33 +333,152 @@ export async function expectComposerGithubAttachmentPill(
export async function assertNewWorkspaceSidebarAndHeader(
page: Page,
input: { serverId: string; previousWorkspaceId: string; projectDisplayName: string },
): Promise<{ workspaceId: string }> {
// Wait for URL to redirect to the newly created workspace.
// Uses URL as source of truth to avoid picking up sidebar rows from concurrent tests.
let workspaceId: string | null = null;
const deadline = Date.now() + 60_000;
while (Date.now() < deadline) {
workspaceId = parseWorkspaceIdFromPageUrl(page, input.serverId);
if (workspaceId && workspaceId !== input.previousWorkspaceId) {
break;
}
await page.waitForTimeout(250);
}
input: {
serverId: string;
client: NewWorkspaceDaemonClient;
previousWorkspaceId: string;
projectDisplayName: string;
assertSidebarRow?: boolean;
assertHeader?: boolean;
},
): Promise<{ workspaceId: string; workspaceName: string; workspaceDirectory: string }> {
// URL is the source of truth so concurrent sidebar rows cannot satisfy this.
await expect
.poll(
() => {
const workspaceId = parseWorkspaceIdFromPageUrl(page, input.serverId);
return workspaceId && workspaceId !== input.previousWorkspaceId ? workspaceId : null;
},
{ timeout: 60_000 },
)
.not.toBeNull();
const workspaceId = parseWorkspaceIdFromPageUrl(page, input.serverId);
if (!workspaceId || workspaceId === input.previousWorkspaceId) {
throw new Error(`Expected URL to redirect to a new workspace.\nCurrent URL: ${page.url()}`);
}
const createdWorkspaceRow = page.getByTestId(
`sidebar-workspace-row-${input.serverId}:${workspaceId}`,
);
await expect(createdWorkspaceRow.first()).toBeVisible({ timeout: 30_000 });
const workspace = await waitForWorkspaceDescriptor(input.client, workspaceId);
await expectWorkspaceHeader(page, {
title: workspaceLabelFromPath(workspaceId),
subtitle: input.projectDisplayName,
if (input.assertSidebarRow !== false) {
const createdWorkspaceRow = page.getByTestId(
`sidebar-workspace-row-${input.serverId}:${workspace.id}`,
);
await expect(createdWorkspaceRow.first()).toBeVisible({ timeout: 30_000 });
}
if (input.assertHeader !== false) {
await expectWorkspaceHeader(page, {
title: workspace.name,
subtitle: input.projectDisplayName,
});
}
return {
workspaceId: workspace.id,
workspaceName: workspace.name,
workspaceDirectory: workspace.workspaceDirectory,
};
}
type WebSocketMessage = string | Buffer;
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 getStringField(input: Record<string, unknown>, key: string): string | null {
const value = input[key];
return typeof value === "string" ? value : null;
}
export interface AgentCreatedDelayControl {
release(): void;
waitForCreateRequest(): Promise<void>;
waitForDelayedCreatedStatus(): Promise<void>;
}
export async function delayBrowserAgentCreatedStatus(
page: Page,
): Promise<AgentCreatedDelayControl> {
const daemonPortPattern = daemonWsRoutePattern();
const createRequestIds = new Set<string>();
const delayedForwards: Array<() => void> = [];
let releaseRequested = false;
let resolveCreateRequest: (() => void) | null = null;
let resolveDelayedCreatedStatus: (() => void) | null = null;
const createRequestSeen = new Promise<void>((resolve) => {
resolveCreateRequest = resolve;
});
const delayedCreatedStatusSeen = new Promise<void>((resolve) => {
resolveDelayedCreatedStatus = resolve;
});
return { workspaceId };
await page.routeWebSocket(daemonPortPattern, (ws) => {
const server = ws.connectToServer();
ws.onMessage((message) => {
const sessionMessage = getSessionMessage(message);
if (sessionMessage?.type === "create_agent_request") {
const requestId = getStringField(sessionMessage, "requestId");
if (requestId) {
createRequestIds.add(requestId);
resolveCreateRequest?.();
}
}
server.send(message);
});
server.onMessage((message) => {
const sessionMessage = getSessionMessage(message);
const payload =
sessionMessage?.type === "status" && typeof sessionMessage.payload === "object"
? (sessionMessage.payload as Record<string, unknown>)
: null;
const requestId = payload ? getStringField(payload, "requestId") : null;
if (payload?.status === "agent_created" && requestId && createRequestIds.has(requestId)) {
resolveDelayedCreatedStatus?.();
if (releaseRequested) {
ws.send(message);
return;
}
delayedForwards.push(() => ws.send(message));
return;
}
ws.send(message);
});
});
return {
release() {
releaseRequested = true;
for (const forward of delayedForwards.splice(0)) {
forward();
}
},
waitForCreateRequest: () => createRequestSeen,
waitForDelayedCreatedStatus: () => delayedCreatedStatusSeen,
};
}

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