* Reapply: Stop completed turns from appearing stuck (#2484) * Reapply: Keep older chat history and image previews stable (#2490) * Lock timeline layout stability with user-driven specs Two reds reproduce the reported jank: - new agent, first prompt: the row paints at top=100 then jumps to 144 through authoritative hydration, because the draft tab and the agent panel each render their own AgentStreamView for the same logical row. - long loaded timeline, submit: scrollHeight collapses 10793 -> 9111 mid-turn, because splitOrderedTail reclassifies measured rows into the virtualized segment where the virtualizer falls back to estimateStreamItemHeight. Also restores centered timeline rows: the pagination anchor wrapper introduced in #2490 was a plain block div, which broke alignSelf: center. * Measure the turn footer blink from when the footer appears Bounding the window at the prompt row conflated two things: the footer arriving a frame after the row, and the footer leaving the layout and coming back. Bounded from the footer's first frame, the blink still reproduces, so the footer-ownership race is real rather than a sampling artifact. * Rename the shadowed prompt row binding in the frame recorder * fix(desktop): allow concurrent worktree dev instances Desktop terminals can inherit another instance's profile path and debugging flags. Scope the profile to the current dev root and select an available debugging port so instances do not share global runtime state. * fix(desktop): expose development providers in dev builds Desktop-managed daemons inherited no Paseo runtime mode, so dev builds constructed the production provider registry. Mark unpackaged internal launches as development while continuing to force packaged launches to production. * fix(timeline): stabilize optimistic turn layout Keep initial history loading dormant until user intent and preserve active-turn layout across delayed transport updates. Add painted-frame coverage for optimistic timeline transitions. * fix(timeline): preserve optimistic prompts through hydration Canonical replacement relied on a transport submission record that can settle before a provider echoes the user row. Keep presentation continuity owned by the unreconciled row while destructive history replacement remains governed by active transactions. * fix(timeline): make submitted prompts canonical Submission settlement previously depended on status updates that could arrive after the optimistic row. Persist every accepted prompt in the canonical timeline and keep the footer active until RPC and canonical acknowledgement converge. * fix(timeline): stop older history pages repeating entries Backward pagination counted source sequence rows, so page boundaries inside a merged projected item could return that item repeatedly. Count projected entries and advance from their display anchors instead, with server and browser regressions covering the contract. * fix(timeline): resume agents from the latest history Use a bounded tail probe on resume so long background runs appear atomically instead of replaying every missed page. Preserve unchanged views, reconcile local/live rows, and keep skipped history reachable through backward pagination. * fix(timeline): reconcile turn state after missed updates Turn completion events can be missed while an agent timeline is unsubscribed, leaving footer activity stale even after the directory reports idle. Reconcile the single-owned turn lifecycle from authoritative resume snapshots while preserving newer live evidence. * test(timeline): cover running state across refresh Protect the hydration boundary where directory state must restore consistent tab, composer, and footer activity. Expose the running indicator semantically so the regression checks user-visible state instead of spinner implementation details. * fix(timeline): keep history loading geometry stable Reserve a fixed history-start gutter regardless of pagination state so showing or hiding the loader cannot shift timeline content. * fix(timeline): keep turn activity coherent Derive footer, tab, and composer activity from one lifecycle and submission projection. Idle directory transitions now request an authoritative resume so missed terminal events converge without treating directory delivery order as turn authority. * fix(tests): align checks with runtime boundaries * fix(desktop): stop dev app surviving its terminal Electron was detached from the desktop runner, allowing it to outlive a stopped workspace terminal with broken output pipes and retain the single-instance lock. Keep Electron owned by the runner and preserve explicit process-group cleanup for detached child trees. * fix(timeline): preserve activity through turn handoff Correlate lifecycle events and snapshots with daemon-owned turn identity, and keep optimistic submissions active until their canonical row arrives. This prevents stale updates and RPC timing from exposing a false idle state. * refactor(timeline): remove submission timing state * fix(timeline): restore prompt identity on resume * fix(timeline): exclude attachments from canonical prompt text Structured provider prompts can include hidden history attachments before the user's text. Persist only plain prompt blocks so authoritative user messages match what the composer displayed while provider input remains unchanged. * refactor(agent): remove unused pending-run message identity * fix(desktop): let Chromium allocate the debugging port * refactor(agent): remove impossible finalized-turn tracking * fix(timeline): load history after keyboard scrolling Treat observed upward displacement as user intent so keyboard and scrollbar navigation can wake dormant pagination. * test(server): preserve accepted turn stream order The manager-owned accepted turn boundary intentionally precedes coalesced output. Assert that lifecycle event explicitly while retaining the timeline-before-terminal ordering contract. * test(app): scope sidebar fixtures by identity Shared E2E daemon state may contain unrelated projects. Keep stale-route coverage tied to its named missing project and drag only the two rows seeded by the reorder spec. * fix(timeline): gate prompt revisions by client capability * fix(timeline): finalize canonical prompt delivery Treat provider identity as a single stored-row transition so legacy clients receive one deliverable canonical prompt. Release identity-pending rows before later foreground output or the current terminal while preserving stale-turn isolation. * fix(server): preserve legacy prompt pagination * fix(timeline): keep assistant block identity stable Assign canonical block IDs during live promotion so terminal finalization cannot remount image-bearing rows. * fix(timeline): apply covered revisions during resume append Run the exact canonical revision pass before overlapping resume pages append newer rows, while leaving cursor advancement to the incremental path. * fix(server): preserve queued agent transitions Project each queued live snapshot before awaiting stored metadata so later lifecycle state cannot overwrite an earlier transition. * fix(server): snapshot queued agent updates Capture each live agent payload before it enters the per-agent queue so shared nested state cannot change while an earlier storage read is pending. * docs(app): date agent turn compatibility cleanup * refactor(timeline): mint submitted prompt identity at acceptance Canonical prompt rows now keep one immutable wire identity while provider identity remains internal for rewind translation. This removes the unreleased same-sequence revision and legacy projection contract. * refactor(app): remove prompt revision reconciliation Submitted prompt rows are immutable on the wire, so resume and live reducers no longer need to apply already-covered same-sequence updates. * test(app): cover submitted prompt rewind roundtrip * docs(timeline): describe immutable submitted prompt identity Provider echoes now enrich daemon-internal identity only; wire rows keep their acceptance-time identity and rewind performs the provider translation. * test(server): validate mock rewind targets Reject unknown provider message IDs so app rewind coverage exercises wire-to-provider identity translation. * fix(server): validate rewind before interrupting * fix(server): omit rewind identity for daemon prompts * test(app): guard rewind eligibility and rejection * fix(server): preserve prompt identity across hydration * refactor(timeline): remove stale revision remnants * Revert "fix(server): preserve prompt identity across hydration" This reverts commit 6adcf7e5b0bc8601761af59b461a746cb5e395fa. * fix(timeline): settle missed submission acknowledgements
27 KiB
Development
Prerequisites
- Node.js (see
.tool-versionsfor exact version) - npm workspaces (comes with Node)
Running the dev server
npm run dev:server
npm run dev:app
npm run dev:desktop
Root checkout dev is intentionally split across terminals:
npm run dev:serverruns the daemon on127.0.0.1:6768.npm run dev:appruns Expo onhttp://localhost:8081and connects to the dev daemon.npm run dev:desktopruns its own Electron-flavored Expo server on the first free port from8082through8089. It never claims port8081.
Desktop dev launches its desktop-managed daemon with PASEO_NODE_ENV=development,
so development-only providers such as Mock Load Test are available. Packaged
desktop launches always force the daemon to production mode.
npm run dev is only a shorthand for npm run dev:server. Keep 127.0.0.1:6767 for the packaged app and production-style ~/.paseo state.
Nix desktop package
The flake exposes packages.<system>.desktop on Linux and macOS:
nix build .#desktop
Linux produces the paseo-desktop launcher and desktop entry. macOS produces
Applications/Paseo.app plus the paseo-desktop launcher. Both use the nixpkgs
Electron runtime and the checkout's built daemon, client, and renderer rather
than downloading a published desktop release.
PASEO_HOME
PASEO_HOME is the directory that holds runtime state (agents, worktrees, workspace config, sockets, daemon log). Resolution rules:
- The server itself (e.g. when launched by the desktop app or
npm run start) defaults to~/.paseo(seepackages/server/src/server/paseo-home.ts). - Repo dev scripts default to
$ROOT/.dev/paseo-home, where$ROOTis 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-homeand configured dev daemon endpoint.- Paseo-created worktrees seed
$PASEO_WORKTREE_PATH/.dev/paseo-homefrom$PASEO_SOURCE_CHECKOUT_PATH/.dev/paseo-homeby 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/iosand the newest.dev/ios-buildentry 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:
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
Daemon endpoints
- 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
8082through8089. npm run dev(Windows):localhost:6767for the daemon.
In Paseo-managed worktree services, use the injected service environment rather than hardcoded root checkout ports.
Expo Router
Route ownership, startup restore, and native blank-screen gotchas live in
expo-router.md. Read it before changing packages/app/src/app,
startup routing, remembered workspace restore, or active workspace selection.
iOS simulator preview service
Paseo worktrees expose the native iOS dev app through the ios-simulator service in paseo.json. The service URL serves the simulator preview at /.sim, so the preview link is ${PASEO_URL}/.sim.
Prerequisites (macOS only). The service shells out to the Apple toolchain, so beyond the npm ci that worktree setup runs you must install:
- Xcode (the full app, not just the Command Line Tools) — install it from the Mac App Store, or from
developer.apple.com/downloadfor a specific version. It providesxcodebuildandxcrun simctl; accept its license and let first-run component installation finish before starting the service. - An iOS Simulator runtime with at least one iPhone device type. Recent Xcode versions may not bundle a runtime — add one via Xcode → Settings → Components (older Xcode: "Platforms"). The service targets
iPhone 16 Proby default (override withPASEO_IOS_DEVICE_TYPE) and falls back to any iPhone; it fails withNo iPhone simulator device type is installedwhen none exist. - Homebrew — CocoaPods itself installs automatically:
expo prebuildrunspod installon a cold worktree, and when the CocoaPods CLI is missing the runner installs it for you. It triesgem install cocoapodsfirst and falls back to Homebrew (brew install cocoapods), so having Homebrew available lets that fallback succeed without a manual step.
serve-sim, Expo, and Metro come from npm ci, and CocoaPods installs itself on the first prebuild as described above.
The service is designed for concurrent worktrees: it derives a deterministic simulator identity from the worktree path, uses the worktree's assigned PASEO_PORT, pins serve-sim to that simulator UDID, and only tears down that worktree's helper/simulator state. It must not rely on the globally booted simulator or any fixed Metro port.
Worktree setup best-effort seeds the generated iOS project and newest native build cache from the source checkout before the service runs. The service still validates the native project by running Expo prebuild and Xcode; the seed only avoids paying all setup/build cost from a cold worktree every time.
Starting the service must not create, focus, reveal, or leave behind macOS Simulator.app windows — a guard hides Simulator.app every 250ms, so the native window vanishes if you focus it. The user-visible surface is the interactive /.sim preview: a serve-sim stream (60 FPS MJPEG + a WebSocket control channel) that Metro mounts at basePath: "/.sim" (packages/app/metro.config.cjs) and that forwards taps and gestures, so first-launch prompts like "Open in PaseoDebug?" are answered there, not in the native window. Open the ${PASEO_URL}/.sim link the service prints — not serve-sim's raw stream port (:3100), which is view-only. Because the stream sits behind the daemon proxy it is convenient for remote viewing but laggy up close; for fast local dev at the Mac, use the native simulator path below.
Troubleshooting. If xcrun simctl fails with unable to find utility "simctl", the active developer directory is still the Command Line Tools even though Xcode is installed. Point it at Xcode: sudo xcode-select -s /Applications/Xcode.app/Contents/Developer, then confirm with xcrun --find simctl.
Running the iOS app on a local simulator
For fast, native, interactive iOS dev at the Mac — as opposed to the remote /.sim preview above — skip the service and build the dev client directly:
npm run ios # → expo run:ios (packages/app): builds and launches the app in the real Simulator.app
expo run:ios starts its own Metro and gives you the normal Simulator.app window (full speed, native touch, no stream).
Pointing the app at a daemon. The client resolves its local daemon from EXPO_PUBLIC_LOCAL_DAEMON (packages/app/src/runtime/host-runtime.ts); when unset it falls back to localhost:6767, the production ~/.paseo daemon. To target a worktree's dev daemon instead, set it on the build command:
EXPO_PUBLIC_LOCAL_DAEMON=localhost:${PASEO_SERVICE_DAEMON_PORT} npm run ios # worktree daemon running as a Paseo service
EXPO_PUBLIC_LOCAL_DAEMON=localhost:6768 npm run ios # standalone `npm run dev:server`
The iOS simulator shares the Mac's loopback, so localhost:<port> reaches the host daemon directly.
Gotcha — EXPO_PUBLIC_* is inlined into the JS bundle at Metro bundle time, not read at runtime. Set it in the same shell that starts Metro. If the app still connects to the old daemon, Metro served a cached bundle; re-bundle clean with cd packages/app && EXPO_PUBLIC_LOCAL_DAEMON=… npx expo start -c and reload the app.
Desktop renderer profiling
npm run dev:desktop starts Electron with Chromium remote debugging enabled so
renderer CPU profiles can be captured through CDP. By default it passes
--remote-debugging-port=0, so Chromium atomically asks the OS for an available
port and prints the selected DevTools endpoint. Set
PASEO_ELECTRON_REMOTE_DEBUGGING_PORT when a QA workflow requires a validated,
fixed port.
Desktop dev also scopes Electron userData to the current dev root. This prevents
desktop-only environment inherited by terminals opened inside Paseo from coupling
a new worktree instance to the parent desktop instance's profile or single-instance
lock.
Electron remains in the desktop dev runner's process group. Closing or stopping the workspace-script terminal must terminate Electron with Metro; detaching Electron leaves an orphan holding the worktree's single-instance lock and broken output pipes.
With desktop dev running, verify the real BrowserWindow, titlebar clearance, fullscreen transition, and 751-pixel settings split with:
npm run verify:electron-cdp --workspace=@getpaseo/desktop
The verifier reads the same EXPO_PORT and
PASEO_ELECTRON_REMOTE_DEBUGGING_PORT environment names as desktop dev. Set an
explicit remote-debugging port for verifier runs, and set both when testing an
isolated instance on non-default ports.
When running a dedicated Electron QA instance against a non-default Expo port, set
EXPO_DEV_URL explicitly. Desktop main defaults to http://localhost:8081, so
PASEO_PORT=57928 alone starts Metro on 57928 but Electron still loads 8081.
React render profiling
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:
- Add stable
RenderProfileboundaries around the suspected root and expensive children. Keep IDs specific enough to compare before and after. - Reproduce against real app state, not toy fixtures, whenever practical.
- Record an idle baseline first. If idle is noisy, fix or account for that before optimizing the interaction.
- Warm up the route, reset profiler samples, run the exact interaction, then
compare
actualDuration, render counts, and per-commit samples. - When a memo boundary still renders, record reasons before changing code. Do not guess from object identity alone.
- 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:
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:
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 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.
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.
Agent Tool Catalog Measurement
Measure the MCP tools/list payload that Paseo injects into agents with:
npm run measure:agent-tools --workspace=@getpaseo/server
The command reports compact JSON bytes, estimated tokens, field totals, largest
tools, and the browser-tools delta. It defaults to the agent-scoped catalog; use
-- --scope=top-level for the unaffiliated /mcp/agents shape and -- --json
for machine-readable output.
paseo.json service scripts
worktree.setup and worktree.teardown accept either a multiline shell script or an array
of commands. Both run sequentially.
Lifecycle commands run in the worktree through a stable script shell: bash
resolved from PATH on macOS/Linux, and PowerShell with -NoProfile on
Windows. They inherit the daemon environment plus Paseo's lifecycle variables;
login and interactive shell startup files are not loaded, and Bash's BASH_ENV
hook is unset. Daemon-run loop verify checks and ACP single-string terminal
commands use the same non-login Bash behavior on macOS/Linux, but preserve their
existing cmd.exe /c string semantics on Windows. Service scripts are separate:
they launch in a terminal and receive the service environment described below.
Because the shell differs per platform, a lifecycle command that must run
everywhere cannot use POSIX-only syntax — VAR=1 cmd env prefixes, $VAR
expansion, cp/rm, or a ./scripts/*.sh entrypoint all fail under PowerShell,
and bash is not guaranteed to exist on Windows. Put that logic in a Node script
that reads what it needs from process.env and invoke it as
node ./scripts/<name>.mjs. This repo's own setup does exactly that in
scripts/seed-worktree-dev-state.mjs and scripts/seed-ios-native-cache.mjs.
{
"worktree": {
"setup": "npm ci\ncp \"$PASEO_SOURCE_CHECKOUT_PATH/.env\" .env\nnpm run db:migrate",
"teardown": "npm run db:drop || true"
}
}
Every scripts entry with "type": "service" receives these environment variables:
| Variable | Value |
|---|---|
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:
{
"scripts": {
"web": {
"type": "service",
"command": "PORT=$PASEO_PORT npm run dev:web"
}
}
}
Service ports use OS ephemeral allocation by default. Set worktrees.servicePorts in
$PASEO_HOME/config.json, or replace it for one project with worktree.servicePorts in
paseo.json. The block accepts an inclusive range such as "3000-4000" or a portScript
executable. Since portScript is executed directly without a shell, it must point to a real executable (e.g., a binary or a script with a proper shebang like #!/bin/sh) rather than an inline shell command or shell pipeline. For inline shell commands or pipelines, wrap them in a small script. portScript runs in the workspace directory with four arguments: service name,
workspace ID, branch name, and worktree path. A missing branch is passed as an empty string. The same
values are available as PASEO_SCRIPTNAME, PASEO_WORKSPACE_ID, PASEO_BRANCH_NAME, and
PASEO_WORKTREE_PATH. The script must print one valid TCP port. Paseo trusts the external allocator,
so the port may already be bound. portScript takes precedence when both values are present.
Bundled daemon web UI
The user-facing guide for this feature (enabling it, reverse proxy, TLS, tunnels, security) lives at public-docs/web-ui.md. This section is the contributor/build reference: how the artifact is produced, bundled, and excluded from desktop packaging.
The daemon can optionally serve the browser web client from the same HTTP server. This is disabled by default.
Enable it for a running daemon with:
paseo daemon start --web-ui
Or set the environment variable:
PASEO_WEB_UI_ENABLED=true paseo daemon start
Or persist it in config.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:
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:
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.
The app Metro config disables Watchman and uses Metro's node crawler for exports. Keep that invariant unless you have verified production app exports on machines with and without Watchman installed; distro Watchman builds can differ in capabilities and change Metro's crawl behavior.
For tighter loops, you can rebuild a single workspace:
- Changed
packages/protocol/src/*orpackages/client/src/*:npm run build:client. - Changed
packages/server/src/*,packages/cli/src/*,packages/relay/src/*, orpackages/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:
npm run acp:version-drift # report stale/non-exact package pins
npm run acp:version-drift:check # same, exits non-zero on drift
npm run acp:version-drift:update # rewrite catalog pins to latest exact versions
The checker updates only package-runner catalog entries. Providers that use a
preinstalled binary such as opencode acp, cursor-agent acp, or goose acp
are reported as skipped because their versions are owned by the user's local
install.
CLI reference
Use npm run cli to run the in-repo CLI from source (npx tsx packages/cli/src/index.ts). The script wraps the CLI with scripts/dev-home.sh, so it automatically uses this checkout's .dev/paseo-home and dev daemon endpoint unless you pass an explicit override. The globally installed paseo binary on macOS is a symlink into the installed Paseo desktop app, not this checkout — use it to drive the desktop's built-in daemon, but use npm run cli when you want to talk to the CLI you are editing.
Canonical automation uses paseo workspace create/ls/archive, paseo heartbeat create/update/delete, and the full paseo schedule group. MCP heartbeat automation is intentionally smaller: create and delete only. Detach remains an explicit user lifecycle action rather than an agent tool. paseo run --new-workspace local|worktree composes workspace creation with agent creation. The old paseo worktree and paseo run --worktree forms are hidden compatibility aliases.
npm run cli -- ls -a -g # List all agents globally
npm run cli -- ls -a -g --json # Same, as JSON
npm run cli -- inspect <id> # Show detailed agent info
npm run cli -- logs <id> # View agent timeline
npm run cli -- agent open <id> # Focus an existing agent in Paseo Desktop
npm run cli -- daemon status # Check daemon status
npm run cli -- clone owner/repo --dir ~/workspace # Clone GitHub repo and register project
Use --host <host:port> to point the CLI at a different daemon:
npm run cli -- --host localhost:7777 ls -a
Desktop integrations can focus an existing agent without creating one or
sending a message. Use paseo://h/<server-id>/agent/<agent-id>, or run
paseo agent open <agent-id>. The CLI reads the local daemon's server ID by
default; pass --server <server-id> when targeting another server.
Agent state
Agent data lives at:
$PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json
Find an agent by ID:
find $PASEO_HOME/agents -name "{agent-id}.json"
Find by content:
rg -l "some title text" $PASEO_HOME/agents/
Provider session files
Get the session ID from the agent JSON (persistence.sessionId), then:
Claude:
~/.claude/projects/{cwd-with-dashes}/{session-id}.jsonl
Codex:
~/.codex/sessions/{YYYY}/{MM}/{DD}/rollout-{timestamp}-{session-id}.jsonl
Testing with Playwright MCP
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
npx expo-doctor
Diagnoses version mismatches and native module issues.
Typecheck
Always run typecheck after changes:
npm run typecheck