Ad Hoc Daemon Testing
Ad-hoc daemon testing
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 throughscripts/supervisor-entrypoint.ts or dist/scripts/supervisor-entrypoint.js;
do not use createPaseoDaemon as a product launch path.
Quick start
/ Detailed source-code truncated for AI context efficiency. /Run with:
npx tsx packages/server/src/server/your-script.tsUsing the test helper
For simpler cases, createTestPaseoDaemon + DaemonClient handles temp dirs and port selection:
import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js";
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.70",
});
await client.connect();
await client.fetchAgents({ subscribe: { subscriptionId: "test" } });
// ... test ...
await client.close();
await daemon.close(); // stops daemon + cleans up temp dirs
The test helper does not expose providerOverrides. In test harnesses, use createPaseoDaemon directly when you need it (see quick start above).
Common client methods
// Provider discovery
const snapshot = await client.getProvidersSnapshot({ cwd: "/tmp" });
const models = await client.listProviderModels("claude");
const modes = await client.listProviderModes("claude");// Agent lifecycle
const agent = await client.createAgent({ provider: "claude", cwd: "/tmp" });
await client.sendMessage(agent.id, "Hello");
const updated = await client.waitForAgentUpsert(agent.id, (s) => s.status === "idle");
Gotchas
1. appVersion gates provider visibility
The daemon hides non-legacy providers (anything other than claude, codex, opencode) from clients that don't send an appVersion >= 0.1.45. The DaemonClient sends no version by default, so custom providers like ACP-based ones will be invisible in snapshot responses.
Always pass appVersion:
const client = new DaemonClient({
url: ws://127.0.0.1:${port}/ws,
appVersion: "0.1.70",
});2. Provider snapshots are async
After the daemon starts, providers are probed in the background. The first getProvidersSnapshot() call will likely return status: "loading" for most providers. Poll until the provider you care about is no longer loading:
let snapshot = await client.getProvidersSnapshot({ cwd: "/tmp" });
for (let i = 0; i < 20; i++) {
const entry = snapshot.entries.find((e) => e.provider === "gemini");
if (entry && entry.status !== "loading") break;
await new Promise((r) => setTimeout(r, 2_000));
snapshot = await client.getProvidersSnapshot({ cwd: "/tmp" });
}3. fetchAgents is required before most operations
Call client.fetchAgents() after connecting. The daemon session expects this handshake before it processes other requests — without it, messages like get_providers_snapshot_request will silently hang.
4. listen: "127.0.0.1:0" for port allocation
Always use port 0 so the OS picks a free port. Never hardcode a port — it will collide with the main daemon or other test runs.
5. Script must live inside packages/server
The test utilities use relative imports through the TypeScript project. Place your script somewhere under packages/server/src/ and import from there. Scripts outside the repo will fail with module resolution errors.
6. Cleanup on failure
Wrap your test logic in try/finally to ensure the daemon stops and temp dirs are cleaned up, even if an assertion fails:
try {
// ... test logic ...
} finally {
await client.close();
await daemon.stop().catch(() => undefined);
await rm(paseoHomeRoot, { recursive: true, force: true });
}7. ACP providers spawn real processes
When testing ACP providers (e.g., Gemini with extends: "acp"), the daemon will spawn real processes to probe for models and modes. The binary must be installed and on PATH. Probing can take 5-15 seconds depending on the provider.
---
Agent Lifecycle
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 live agent in AgentManager carries a lastStatus of initializing, idle, running, or error. closed is the persisted, resumable state for an agent record that has no live provider runtime. State transitions persist to disk and stream to subscribed clients via WebSocket.
Runtime residency
An unarchived agent may be closed without being deleted or archived. Closing releases its provider
processes and subscriptions while retaining its Paseo identity, persistence handle, timeline,
workspace, labels, title, usage, attention, timestamps, and parent relationship. Opening or prompting
the agent runs through ensureAgentLoaded(), which resumes the durable provider session under the
same Paseo agent ID. Provider history is not appended again when the canonical timeline is already
primed.
Idle agents remain resident indefinitely. Runtime closure happens only through an explicit lifecycle
action such as archive, replacement, reload, workspace teardown, or daemon shutdown.
A provider runtime can still die on its own — crash, OOM kill, host suspend. Work the agent parked
inside that process dies with it: Claude Code's background Bash shells, Monitor watches, and
workflows all live in the CLI process, and the completion notification that would have woken the
agent never arrives. A runtime that dies mid-turn is reported by whatever is draining its stream, but
between turns nothing is watching, so the agent sits at idle looking healthy while its background
work is gone. Report that exit as a turn failure so the agent lands in error with a timeline entry.
Only the Claude provider does this today; the others still report a death only when a turn happens to
be in flight.
Cancellation
Cancellation changes lifecycle state only after the provider acknowledges the interrupt or emits a terminal turn event. If the interrupt is rejected or times out, the agent remains running with its active foreground turn intact. Follow-up actions such as replacement, reload, rewind, and Stop must report that failure instead of accepting work they cannot perform. Synthesizing a local cancellation without provider acknowledgment creates a split-brain session: Paseo accepts a new prompt while the provider still owns the previous foreground turn.
Relationships
Agents can launch other agents via the agent-scoped create_agent MCP tool. Agent-scoped creation is always asynchronous and always stamps paseo.parent-agent-id, pointing back at the caller. Omit workspaceId to use the caller's workspace, or pass an existing workspace ID returned by create_workspace. Placement never changes parentage.
- Subagents — exist as part of the creating agent's work, appear in that agent's subagent track, and are archived with it.
- Detached agents — stand on their own after an explicit detach transition, do not appear in the former parent's subagent track, and are not archived with it.
Parent archive detaches a subagent instead of archiving it when either condition holds:
- The child belongs to another workspace.
- The child is currently open in an agent tab.
All other children archive with the parent. After the workspace layout hydrates, the client marks
every managed subagent present in its tabs with paseo.open-agent-tab.<client-id>=true through the
generic agent metadata update. This includes background and restored tabs; navigation does not own
the marker. Closing a tab sets that client's label to false. Any true client label keeps the child
open. Detach clears the parent and every open-tab label. The surviving child therefore becomes a
normal root agent immediately, and closing its still-open tab archives it.
Runtime ownership is resolved from explicit workspace ID and caller context, never from cwd. Workspace creation is a separate operation with local | worktree isolation; agent creation only selects an existing workspace.
Users can also detach an existing subagent from the subagents track. Detach is deliberately a manual lifecycle gesture, not an agent-facing MCP tool. It removes the parent and open-tab lifecycle labels: it does not stop, archive, move, or restart the agent. The agent keeps its current cwd and workspaceId, leaves the former parent's track, and behaves like a root agent for tab close, workspace activity, and future parent archive.
notifyOnFinish defaults to true for agent-scoped creation and background prompt follow-ups because most delegated work needs to report back to the creating agent. Set it to false only for truly fire-and-forget agents or prompts.
Permission requests are notification checkpoints, not the end of that subscription. The caller is notified again after a permission response when the child finishes, errors, or requests another permission.
The permission notification includes the normalized request plus the child and request IDs, so the caller can inspect it and respond without fetching agent status.
A watched child that closes before its finish event also notifies the caller so delegated work cannot disappear silently during archive or workspace teardown.
Provider-managed child agents
Some providers can create their own child sessions inside one provider runtime. OMP's task tool reports these with child_session events; AgentManager imports the live provider handle, stamps paseo.parent-agent-id, and surfaces the result as a normal subagent in the parent's subagents track.
The provider still owns the underlying runtime. Paseo keeps an agent record so the child can be opened, tracked, archived, and cascaded with the parent, but prompts and history hydration route through the provider adapter for that native child handle.
Archive
Archive is a soft delete: the agent record stays on disk with archivedAt set, the runtime is closed, and the agent disappears from active lists. Archive is global — it lives on the server and propagates to every connected client.
Archive sets archivedAt, invokes the provider's native archive hook, and cascades to managed
children.
create_agent_request can opt an agent into autoArchive. In that mode the daemon archives the agent after the first terminal turn event (turn_completed, turn_failed, or turn_canceled). When the agent owns an isolated workspace, auto-archive archives that workspace too; the managed worktree is removed when its final workspace reference is gone.
Archiving runs through AgentManager.archiveAgent (packages/server/src/server/agent/agent-manager.ts):
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. Resolve children — detach cross-workspace and open-tab children; cascade-archive the rest recursively
Cascade is what keeps subagent fleets from outliving their orchestrator.
Workspace archive is a separate lifecycle. Archiving or removing a worktree can close a surviving
agent record without setting the agent's archivedAt, while its workspaceId still points at the
archived workspace. History navigation must not infer workspace lifecycle from agent.archivedAt
or mutate either lifecycle. The workspace route asks the daemon for authoritative recovery state;
only the route's explicit Unarchive or Restore action changes the archived workspace.
History navigation preserves the selected agent as an explicit recovery target. If both that agent
and its workspace are archived, the workspace recovery action restores the workspace and unarchives
the selected agent as one user action. Other archived agents in the restored workspace remain
recoverable from History. Opening one pins its tab and renders the archived-agent callout. Authoritative
timeline catch-up may load provider history with a runtime-only history resume purpose, which must
leave both Paseo's archivedAt and the provider's native archive state unchanged. Unarchive remains
the only transition back to an interactive runtime: it runs the provider's native unarchive hook
(including Codex thread/unarchive) before the normal agent resume and timeline hydration flow.
Provider session connection owns every process it spawns until the session is registered withAgentManager. If initialization, persisted-session resume, or initial history hydration fails,connect() must dispose that process before rethrowing; the manager cannot clean up a session it never
received.
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 app clears the current client's open-tab label before removing the tab. Another client's open tab remains protected. The agent stays unarchived and stays in its parent's track, so a later parent archive cascades to it when no client still has it open. The user can re-open the tab from the track at any time. Single and bulk tab close apply the same policy.
The asymmetry is intentional: a subagent's persistent relationship lives in the parent's track. Same-workspace subagents are not auto-opened as tabs; the user opens one from that track when needed. A cross-workspace subagent is also auto-opened as a tab in its own workspace so opening that workspace does not appear empty. It remains in the parent's track until it is actually detached.
Workspace activity
Agent lifecycle status stays literal: a parent agent is idle when its own turn is idle, even if a child is running.
Workspace status is an aggregate activity signal computed per workspaceId. Ownership is never derived from cwd — many workspaces may share one directory, and same-cwd siblings do not clump under one status. Root agents and cross-workspace subagents contribute their normal state bucket to their own workspace. Same-workspace descendants contribute running to the nearest ancestor in that workspace; their non-running attention, permission, and error states stay in the parent's subagents track. This makes a cross-workspace subagent behave like a detached agent for workspace visibility and status without removing its parent relationship.
Running provider-native subagents contribute running to the workspace owned by their parent agent. Their completed, failed, and canceled states stay in the parent's subagents track.
The subagents track
The collapsible track above the composer in an agent's pane (packages/app/src/subagents/track.tsx) combines two kinds of children:
- Paseo subagents are full managed agents. Their membership rule (packages/app/src/subagents/select.ts) is:
parentAgentId === thisAgent.id AND !archivedAt- Provider subagents are child executions owned by Claude, Codex, or OpenCode. They are not inserted into AgentManager as managed agents. Providers emit a separate descriptor and timeline stream through agent.provider_subagents.*; the client keeps that state outside the normal agent store and merges only the presentation rows into the track.
Clicking either kind opens a workspace tab. A Paseo subagent tab is a normal interactive agent pane. A provider subagent tab is a read-only timeline pane with no composer, archive, detach, rewind, or fork actions. Both panes use AgentStreamView, so message, reasoning, tool-call, and layout rendering stay identical.
Provider timelines use the same structural timeline item format but deliberately have a separate lifecycle and transport. A provider thread/session identifier is not a Paseo agent identifier, and closing its tab is always layout-only.
Provider descriptors may include one compact subtitle. The provider owns its contents and formatting; clients display and truncate it without interpreting provider-specific model, thinking, or usage fields.
Claude provider subagents: the task protocol
Claude Code announces subagent lifecycle on the SDK stream (task_started / task_updated / task_notification / task_progress), and Paseo reads those announcements rather than reconstructing them from sidechain frames. The live source (subagents/live-source.ts) and the replay source (subagents/replay-source.ts) both translate into one observation vocabulary (subagents/observation.ts), so a fact is derived once for both paths instead of once per path. Gotchas that are not obvious from the SDK types:
- Not every announced task belongs in the track. Task subagents announce as local_agent and workflows as local_workflow; a backgrounded shell announces as local_bash with the same tool_use_id shape, and ambient housekeeping sets skip_transcript. The Claude provider normalizes a workflow to a generic provider-subagent descriptor titled Workflow, using Claude's summary as its description and timeline opener. Shared storage, protocol, and UI do not distinguish it from another provider subagent.
- A task that was never declared gets no descriptor, by any route. Filtered tasks still emit task_notifications carrying a tool_use_id, and still emit frames carrying parent_tool_use_id. Attributing either produces a descriptor with no identity and a defaulted running status — a nameless row that never finishes. Status, presentation updates, and sidechain frames all route through the declaration table.
- Task ids are session-scoped, not turn-scoped. Cancelling a turn must not clear the routing table: a backgrounded child settles after the interrupt and needs its descriptor to still exist. Cancellation instead terminalizes the declared children that were running in the foreground, and a later task_notification is free to correct that guess. Backgrounded children are identified by task_updated.patch.is_backgrounded.
- Effort is only reachable through hooks. It appears nowhere on the message stream at any depth, and the level Paseo requests is not necessarily the level that runs — a model that does not support it is silently downgraded. A hook firing inside a subagent reports the active post-downgrade level next to its agent_id, which is the same id task_started calls task_id.
- Backgrounded subagents emit no frames carrying parent_tool_use_id at all. Everything keyed off that field sees nothing for one; they are visible only because the task protocol announces them.
- On replay, <session>/subagents/ holds every descendant, not just this session's children. agent-<id>.meta.json carries spawnDepth: 1 is a direct child, 2+ was spawned by another subagent and its toolUseId names a Task call made inside its parent's session, which nothing in this transcript can resolve. Replaying those adds rows the live stream never showed, each with no Task card and no recoverable outcome, so they render as running forever. One recorded session showed 10 subagents live and would have replayed 22.
- Replay totalTokens is a context-size reading, not cumulative spend. Claude Code finalizes a subagent by summing the _last_ assistant message's usage block and shipping that as usage.total_tokens. Summing per-entry usage instead multiplies the cached prefix by the turn count and reports a number several times larger than the live path.
Archived Paseo subagents disappear from the track, by design. To remove one from the track without closing its tab, use the archive button on the row — it opens a confirm dialog and archives the subagent on confirm. Provider-owned rows have no individual Paseo lifecycle controls.
The track header's Archive finished action covers every finished row. It archives idle or errored managed Paseo subagents one at a time, and hides completed, failed, or canceled provider-owned rows in the current app session. Native sessions and timelines are untouched. Running and initializing children remain in the track. If a hidden provider child starts running again, the app brings it back to the track.
To keep the agent alive but remove it from the parent's track, use detach. The daemon clears the relationship lifecycle labels, emits the normal agent update, and every client reclassifies the agent from subagent to root/sibling from that updated snapshot.
Why this shape
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. Managed Paseo subagents can be archived individually or with Archive finished. That action hides finished provider-owned rows locally; this presentation state resets when the app restarts.
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 for agent-scoped creation and removed by detach |
| labels["paseo.open-agent-tab.<client-id>"] | string? | "true" protects an open tab on that client; detach clears every matching label |
| lastStatus | AgentStatus | initializing / idle / running / error / closed |
See docs/data-model.md for the full agent record.
---
Android
Android
App variants
Controlled by APP_VARIANT in packages/app/app.config.js (vanilla Expo, no custom Gradle plugin):
| Variant | App name | Package ID |
| ------------- | ----------- | ---------------- |
| production | Paseo | sh.paseo |
| development | Paseo Debug | sh.paseo.debug |
EAS profiles: development, production, and production-apk in packages/app/eas.json.
development uses Android debug.
Version codes
packages/app/app.config.js derives Android versionCode from the package version with:
major 1_000_000 + minor 1_000 + patchPrerelease metadata is ignored, so 0.1.102-beta.1 and 0.1.102 both produce 1102. The same value is used as the iOS buildNumber because packages/app/eas.json uses EAS's local app version source. Do not re-enable EAS remote version counters or Android autoIncrement; F-Droid and other source-based builders need the native build number to be visible in the repo.
The formula reserves three digits each for minor and patch. If either reaches 1000, change the formula before cutting that release.
Prerequisites (local dev)
Local Android builds run on macOS (or Linux) and need the Android toolchain, pinned in .tool-versions (java 21, android-sdk 21.0) and wired up by .mise.toml (which derives ANDROID_HOME and the command-line tool paths from the android-sdk entry). With mise:
mise install # java 21 + android-sdk 21.0 command-line toolsPin a realandroid-sdkversion, notlatest. The miseandroid-sdkplugin'slatestresolved to the ancient1.0bundle, whosesdkmanager(3.6.0) predates theemulatorpackage and fails withFailed to find package emulator.21.0ships a currentsdkmanager. If you bump it, update only the version in.tool-versions;.mise.tomlderives its paths from that tool entry.
mise install only lays down the command-line tools. Install the rest and create an emulator. On Apple Silicon:
sdkmanager --licenses
sdkmanager "platform-tools" "emulator" "platforms;android-35" "build-tools;35.0.0" \
"system-images;android-35;google_apis;arm64-v8a"
avdmanager create avd -n paseo -k "system-images;android-35;google_apis;arm64-v8a" -d pixel_7
emulator @paseo # start it; leave runningOn an Intel Mac, use the x86_64 system image:
sdkmanager --licenses
sdkmanager "platform-tools" "emulator" "platforms;android-35" "build-tools;35.0.0" \
"system-images;android-35;google_apis;x86_64"
avdmanager create avd -n paseo -k "system-images;android-35;google_apis;x86_64" -d pixel_7
emulator @paseo # start it; leave runningGradle auto-fetches the platform/build-tools it needs once licenses are accepted, so adjust android-35 only if it asks for a different level.
Local build + install
From repo root:
npm run android:development # Debug build
npm run android:production # Release build
npm run android:clear # Remove generated Android projectFor a production-ID release APK that local Android profiling tools can attach to:
PASEO_PROFILE_BUILD=1 npm run android:productionThis keeps the sh.paseo package id, release Hermes bundle, and release optimizations. It adds<profileable android:shell="true" /> and enables local Android trace markers for workspace mounts
and daemon WebSocket traffic. The markers contain message types and sizes, never payload contents,
and emit only while a system trace records the sh.paseo app (perfetto -a sh.paseo ...).
Or from packages/app:
Debug
npx cross-env APP_VARIANT=development expo prebuild --platform android --non-interactive
npx cross-env APP_VARIANT=development expo run:android --variant=debugRelease
npx cross-env APP_VARIANT=production expo prebuild --platform android --non-interactive
npx cross-env APP_VARIANT=production expo run:android --variant=releaseClear generated Android project
rm -rf androidRunning on an emulator against a worktree daemon
npm run android builds and installs the dev client, but two connections have to reach your Mac from inside the emulator — Metro (the JS bundle) and the Paseo daemon — and the emulator does not share the host's loopback: localhost inside the emulator is the emulator itself. Reach the host at 10.0.2.2 (the standard AVD's host alias) for both:
REACT_NATIVE_PACKAGER_HOSTNAME=10.0.2.2 \
EXPO_PUBLIC_LOCAL_DAEMON=10.0.2.2:$PASEO_SERVICE_DAEMON_PORT \
npm run android- REACT_NATIVE_PACKAGER_HOSTNAME=10.0.2.2 — without it, Expo bakes your Mac's LAN IP into the dev client's Metro URL, which the emulator can't route to, and the app dies with Failed to connect to /<lan-ip>:8081 before any JS loads.
- EXPO_PUBLIC_LOCAL_DAEMON=10.0.2.2:<port> — the client's daemon endpoint (packages/app/src/runtime/host-runtime.ts); when unset it defaults to localhost:6767, the production daemon. Use $PASEO_SERVICE_DAEMON_PORT for a worktree daemon running as a Paseo service, or 6768 for a standalone npm run dev:server. It is inlined into the JS bundle at Metro bundle time, so set it on the build command and clear the Metro cache (npx expo start -c) if a change doesn't take.
Alternative — adb reverse + localhost (if 10.0.2.2 misbehaves):
adb reverse tcp:8081 tcp:8081
adb reverse tcp:$PASEO_SERVICE_DAEMON_PORT tcp:$PASEO_SERVICE_DAEMON_PORT
REACT_NATIVE_PACKAGER_HOSTNAME=localhost \
EXPO_PUBLIC_LOCAL_DAEMON=localhost:$PASEO_SERVICE_DAEMON_PORT \
npm run androidThis is the Android counterpart of the iOS local-simulator flow in development.md: on iOS the simulator shares the Mac's loopback so localhost:<port> works directly; on Android you need 10.0.2.2 or adb reverse.
F-Droid / source-only Android builds
F-Droid builds should set PASEO_FDROID_BUILD=1 when running Expo prebuild:
cd packages/app
PASEO_FDROID_BUILD=1 APP_VARIANT=production npx expo prebuild --platform android --clean --non-interactive
cd android
PASEO_FDROID_BUILD=1 ./gradlew assembleRelease --no-daemon --max-workers=1 -Dorg.gradle.parallel=falseThe flag must be present for both prebuild and Gradle because Gradle starts Metro for the release bundle. Keep the source build serial and daemon-free as shown above: compiling every Expo module can exhaust memory when Gradle workers run in parallel. The profile enables source-built Expo modules, excludes the proprietary camera, Firebase notification, and Expo development-client native modules, disables Gradle dependency metadata, and substitutes JavaScript stubs for camera and notifications. The resulting app supports direct and pasted-link pairing but not QR scanning or push notifications.
For a single-ABI APK, pass React Native's architecture property to Gradle:
PASEO_FDROID_BUILD=1 ./gradlew assembleRelease \
-PreactNativeArchitectures=arm64-v8a \
--no-daemon --max-workers=1 -Dorg.gradle.parallel=falseSupported values are armeabi-v7a, arm64-v8a, x86, and x86_64. The F-Droid profile filters native libraries to that ABI and changes the APK version code to baseVersionCode 10 + abiSuffix, where the suffixes are ordered 1 through 4 in that same sequence. F-Droid metadata should use four build blocks with VercodeOperation entries 10 %c + 1 through 10 * %c + 4 and pass the matching reactNativeArchitectures value in each build command. Builds without a single architecture keep the base version code.
Keep the excluded npm packages installed. Normal builds use them, while the F-Droid profile removes only their Android native modules and config plugins. Paseo always applies expo-gradle-jvmargs with -Xmx4096m and -XX:MaxMetaspaceSize=1024m so local Expo prebuilds have enough Gradle heap whether they use precompiled AARs or source-built Expo modules.
The EAS production-apk profile uses the large Android resource class. Release builds compile the native ABIs and run Hermes bundling in the same Gradle invocation; the default worker can exhaust its remaining memory and kill Hermes with exit code 137 even when Gradle's own heap is correctly sized.
React version lockstep
Keep react and react-dom pinned to the React version embedded by the current react-native release. React Native 0.81.x embeds react-native-renderer 19.1.0, so packages/app must use React 19.1.0. Bumping React to a newer patch can build successfully but crash at JS startup on Android with Incompatible React versions, leaving the app on the native splash screen.
Screenshots
adb exec-out screencap -p > screenshot.pngCloud build + submit (EAS)
Stable tag pushes like v0.1.0 trigger:
- 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
cd packages/appRecent builds
npx eas build:list --limit 10 --non-interactive --json | jq '.[] | {platform, status, appVersion, gitCommitHash}'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 for the full mobile-build babysitting flow.
---
Architecture
Architecture
Paseo is a client-server system for monitoring and controlling local AI coding agents. The daemon runs on your machine, manages agent processes, and streams their output in real time over WebSocket. Clients (mobile app, CLI, desktop app) connect to the daemon to observe and interact with agents.
Your code never leaves your machine. Paseo is local-first.
System overview
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Mobile App │ │ CLI │ │ Desktop App │
│ (Expo) │ │ (Commander) │ │ (Electron) │
└──────┬───────┘ └──────┬──────┘ └──────┬──────┘
│ │ │
│ WebSocket │ WebSocket │ Managed subprocess
│ (direct or │ (direct) │ + WebSocket
│ via relay) │ │
└───────────┬───────┴──────────────────┘
│
┌──────▼──────┐
│ Daemon │
│ (Node.js) │
└──────┬──────┘
│
┌────────────┼────────────┬────────────┬────────────┐
│ │ │ │ │
┌─────▼─────┐ ┌───▼────┐ ┌──────▼─────┐ ┌────▼─────┐ ┌────▼────┐
│ Claude │ │ Codex │ │ Copilot │ │ OpenCode │ │ Pi │
│ Agent │ │ Agent │ │ Agent │ │ Agent │ │ Agent │
│ SDK │ │ Server │ │ ACP │ │ │ │ │
└───────────┘ └────────┘ └────────────┘ └──────────┘ └─────────┘Components at a glance
- Daemon: Local server that spawns and manages agent processes and exposes the WebSocket API.
- App: Cross-platform Expo client for iOS, Android, web, and the shared UI used by desktop.
- CLI: Terminal interface for agent workflows that can also start and manage the daemon.
- Desktop app: Electron wrapper around the web app that bundles and auto-manages its own daemon.
- Relay: Optional encrypted bridge for remote access without opening ports directly.
Packages
packages/server — The daemon
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
- 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)
All paths are under packages/server/src/.
Project identity is daemon-global rather than session-owned. After registry bootstrap, the daemon's
project Git observer keeps one non-recursive watch on each lexically equivalent active project root
and listens only for the root .git entry, with a slow rescan as a missed-event fallback. It runs
for empty projects and without connected clients, then fans metadata changes through the WebSocket
server to capability-aware sessions. It deliberately does not use the broad recursive working-tree
watcher or the per-session Git observer: those are checkout/status mechanisms and intentionally do
not retain non-Git directories.
Key modules:
| Module | Responsibility |
| ------------------------------- | ----------------------------------------------------------------------------- |
| server/bootstrap.ts | Daemon initialization: HTTP server, WS server, agent manager, storage, relay |
| server/websocket-server.ts | WebSocket connection management, hello handshake, binary frame routing |
| server/session.ts | Per-client session state, timeline subscriptions, terminal operations |
| server/directory-sync/ | Daemon-global latest-state sequences for projects, workspaces, and agents |
| server/agent/agent-manager.ts | Agent lifecycle state machine, timeline tracking, subscriber management |
| server/agent/agent-storage.ts | File-backed JSON persistence at $PASEO_HOME/agents/ |
| server/agent/tools/ | Transport-neutral catalog for workspaces, agents, permissions, and automation |
| server/agent/mcp-server.ts | Thin MCP adapter that registers the Paseo tool catalog with the MCP SDK |
| server/agent/providers/ | Provider adapters (see "Agent providers" below) |
| server/relay-transport.ts | Outbound relay connection with E2E encryption |
| server/schedule/ | Cron-based scheduled agents |
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]/workspace/[workspaceId], /h/[serverId]/agent/[agentId], etc.). The workspaceId URL segment is an opaque workspace id, not a directly meaningful filesystem path.
- HostRuntimeController manages saved host connections, reconnection, and per-host runtime state
- runtime/replica-cache keeps the complete project, workspace, and active-agent directory plus one short focused timeline tail in AsyncStorage. It restores before navigation becomes ready and leaves remote hydration flags false.
- runtime/directory-sync owns directory reconciliation. On reconnect it passes the persisted per-entity cursor through project.list, fetch_workspaces, and fetch_agents; the daemon returns each entity's latest projection when its sequence is newer, plus tombstones.
- SessionContext wraps the daemon client for the active session
- Composer UI and submit/draft behavior live in packages/app/src/composer/; screens and panels should integrate it from there instead of dropping composer internals into components/, hooks/, or screens/workspace/
- Timeline reducers in timeline/session-stream-reducers.ts handle compaction, gap detection, sequence-based deduplication
- Timeline sync correctness is documented in docs/timeline-sync.md: live streams are for immediacy, fetch_agent_timeline_request is authoritative, and catch-up is paged but complete.
- Voice features: dictation (STT) and voice agent (realtime)
The replica cache paints stale data immediately while the host connects. Directory cursors are
reconciliation checkpoints; cached entities remain non-authoritative until the daemon answers.
Pending permission requests are not restored from it. AsyncStorage is not encrypted, so the cached
timeline tail may contain source code, prompts, and tool output; encrypted-at-rest storage is a
separate product/security decision. Its serialized payload has a 32 MiB byte budget and evicts whole
host snapshots in least-recently-written order; a single oversized host is omitted rather than
partially restored. Browser and Electron builds store it in IndexedDB. Native builds use
AsyncStorage, and Android reserves 64 MiB for that database.
The three directory entity types have independent monotonic sequences and share one daemon
generation. The daemon retains only the latest projection per entity and bounded tombstones, not an
event log. A missing, expired, or previous-generation cursor receives a full snapshot. Projects are
independent records; a project with no workspaces does not need a workspace placeholder.
packages/cli — Command-line client
Commander.js CLI with Docker-style commands. Common agent operations are also exposed at the top level (e.g. paseo ls, paseo run).
- 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 terminal ls/create/capture/send-keys/kill
- paseo script ls/start/stop
- paseo schedule create/ls/inspect/update/pause/resume/run-once/logs/delete
- paseo heartbeat create/update/delete
- paseo workspace create/ls/rename/archive
- paseo permit allow/deny/ls
- paseo provider ls/models
- hidden legacy paseo worktree create/ls/archive compatibility alias
- paseo speech …
Communicates with the daemon via the same WebSocket protocol as the app.
packages/relay — Relay transport and E2E encryption
Enables remote access when the daemon is behind a firewall.
- Curve25519 ECDH key exchange + XSalsa20-Poly1305 (NaCl box) encryption
- The relay is zero-knowledge — it routes encrypted bytes and cannot read content
- Client and daemon channels with identical API (createClientChannel, createDaemonChannel)
- Pairing via QR code transfers the daemon's public key to the client
- New homes keep relay disabled until pairing consent. DaemonConfigStore persists the desired state, while the relay runtime starts or stops the outbound transport live; pairing reads that current state instead of a startup snapshot.
- Optional E2EE capability negotiation preserves application frame kind: text plaintext uses base64 ciphertext text frames, while binary plaintext uses raw ciphertext binary frames; mixed-version peers remain base64-only
- 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
The production relay server lives in getpaseo/paseo-relay. It is a distributed Elixir service. The Cloudflare relay implementation in this monorepo is retained as legacy code and is not deployed.
See SECURITY.md for the full threat model.
Paseo Hub
The optional Hub relationship is daemon-outbound and does not use the relay. Its connection,
authorization, ownership, persistence, and lifecycle contract is documented in hub.md.
packages/desktop — Desktop app (Electron)
Electron wrapper for macOS, Linux, and Windows.
- Can spawn the daemon as a managed subprocess
- Native file access for workspace integration
- Same WebSocket client as mobile app
Multi-window (hybrid land-on model). createWindow() in main.ts is reusable: ⌘⇧N/File→New Window, relaunching the app (second-instance), and the sidebar "Open in new window" action each open a fresh BrowserWindow. Every window shows the full sidebar — there is no per-window project ownership or filtering. "Land on a project" is delivered by a per-webContents PendingOpenProjectStore: each window pulls its own pending project path on mount (paseo:get-pending-open-project) and runs the normal open-project flow, identical to a CLI paseo <path> launch.
Window-state v1 limitation: only the _first_ window of a session restores and persists saved geometry (size/position/maximized). Windows opened via ⌘⇧N / second-instance / "Open in new window" open at the default size, OS-cascaded, and do not persist — this avoids every window stacking on the same restored bounds and fighting over the single window-state store. Lifting this needs per-window state keys.
> In-app browser profile. Every browser guest uses one stable persistent Electron session, so cookies, authentication, cache, and site storage are shared across tabs, workspaces, and desktop windows and survive tab or app closure. Browser identity is independent of that storage partition: after everydid-attach, the renderer explicitly registers its browser id, workspace id, and current guestWebContentsid, and main accepts the registration only when that guest belongs to the calling renderer and the shared profile. Registration is intentionally repeated because reparenting a retained<webview>can replace its guest without replacing the DOM element. Settings > General > Clear browser data is the sole profile-deletion path; it clears the shared session and reloads live guests without deleting saved tabs or URLs.
> In-app browser window opens. Ordinary link opens, including Shift-clicked links, become Paseo workspace tabs. Script-created opens with popup features or a named window target and POST-backed opens remain secured Electron child windows in the shared browser profile, preservingwindow.opener,postMessage, named-window reuse, request bodies, andwindow.close()for OAuth, payment, and similar popup protocols. Unsupported URL schemes are denied before either path.
> In-app browser ownership. Each registered guest records its owning host window. The active browser is keyed by(host window, workspace), and application-menu Reload / Force Reload resolve only within the window Electron supplies to the menu callback. A non-null active update must name a browser owned by that host; a null update clears only that host/workspace. Browser automation continues to target explicit browser ids returned bybrowser_new_taborbrowser_list_tabs.
> Browser keyboard boundary. Guest pages receive renderer-published shortcuts first.Cmd/Ctrl+LandCmd/Ctrl+Rare explicit guest-shell reservations; ordinary Paseo shortcuts run only after the page declines them. The sandboxed guest preload runs in every frame so focused iframes use the same boundary, while Node integration remains disabled. Human guest input disables Electron's menu fallback for plain keys. Agent-generated keys use guestsendInputEventwithskipIfUnhandled, so an unhandled Enter stops at the guest instead of reaching the host composer. Main selects the preload; it exposes no APIs to guest pages.
Human key -> guest WebContents
|-- Cmd/Ctrl+T/L/R ----------> reserved browser-shell action
-- page keydown
|-- page prevents ------> page owns it
-- published shortcut -> guest preload -> IPC(browserId) -> Paseo resolverAgent browser_keypress -> guest sendInputEvent(skipIfUnhandled)
|-- guest handles ------------> page owns it
-- guest does not handle ----> stop; never redispatch to the host window
packages/website — Marketing site
TanStack Router + Cloudflare Workers. Serves paseo.sh.
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 {
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 }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 or RFC6455 control ping. Current clients ping every 10 seconds, beginning one interval after connecting. The first ping claims an application-ownership lease for that physical socket, all later inbound activity renews it, and the daemon forcibly terminates the socket if the lease expires. A legacy or raw socket that never sends an application ping never enters this lease and is not closed for omitting one. Session RPC timeouts are operation failures and must not be treated as proof that the socket is dead.
Every physical send path enforces an 8 MiB outbound high-water mark, including JSON broadcasts, binary terminal frames, and the encrypted relay adapter's asynchronous queue. This sits above the terminal stream's 4 MiB soft backpressure threshold, leaving room for snapshot catch-up before the hard cutoff. JSON is serialized once per broadcast after sockets already at the limit are removed, then its exact byte length is checked for every remaining socket. A frame that would cross the limit is not sent; that physical socket is forcibly terminated without disturbing other sockets attached to the same logical session. Multiple tabs and simultaneous direct and relay paths may legitimately share a client id.
Client session RPC waits default to 60s so slow relay or mobile networks do not turn a live but delayed daemon response into a false operation failure. Keep connect timeouts, app-level grace windows, explicit diagnostic latency probes, liveness ping timers, and genuinely long-running RPCs separate from this default.
New session RPCs use dotted names with .request and .response suffixes, such as checkout.forge.set_auto_merge.request and checkout.forge.set_auto_merge.response. See rpc-namespacing.md 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 agentworkspace_update
- , script_status_update, workspace_setup_progress — Workspace stateagent_permission_request
- / agent_permission_resolved — Tool-call permission flowagent_deleted
- , agent_archived, agent_status, agent_listcheckout_status_update
- , checkout_diff_update, and the full checkout_* request/response set for git operations
Agent snapshots optionally carry the daemon-owned active turn identity, and turn lifecycle stream events
optionally carry the same turnId. New clients use these fields when present and normalize an old daemon's
status once at the directory boundary rather than maintaining a second activity model.
- Terminal subscribe/input/capture commands
- Voice/dictation streaming events (dictation_stream_*, assistant_chunk, audio_output, transcription_result)requestId
- Request/response pairs for fetch, list, create, etc., correlated by ; failures use rpc_error
directory_suggestions_request is one daemon-owned filesystem search capability. The daemonsearchDirectoryEntries
configures the same engine with a root, output format, path-query policy,cwd
entry-kind filters, match mode, blank-query behavior, and hidden-directory traversal policy. A
request without searches the host home for absolute project paths; a request with cwddirectories
searches that workspace and returns relative entries. Clients may prepend their small host-scoped
recent-project list for bare queries, but must not parse filesystem query syntax or re-filter a
correlated daemon response. The legacy response field remains a projection of theentries
typed list.
Binary frames (terminal stream protocol):
Terminal I/O is sent as binary WebSocket frames decoded by decodeTerminalStreamFrame in shared/binary-frames/terminal.ts. The layout is:
- 1-byte opcode: Output (0x01), Input (0x02), Resize (0x03), Snapshot (0x04){ rows, cols }
- 1-byte slot: terminal slot id
- variable payload: bytes for output/input, JSON-encoded 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.
File downloads keep the existing FileBegin/FileChunk/FileEnd framing and stream 256 KiB chunks
from one stable file handle. Each transfer awaits completion of its own physical WebSocket send before
reading the next chunk; it is scoped to the requesting physical socket and does not queue unrelated
messages or transfers.
Compatibility rules
- WebSocket schemas are append-only. Add fields, do not remove fields, and never make optional fields required.
- New wire enum values must be gated at serialization with session.supports(CLIENT_CAPS.someCapability).Session
- stores client capabilities from the hello handshake and rehydrates them on reconnect, so the wire boundary can ask one question: session.supports(...).
Example: adding a new enum value
// 1. Add CLIENT_CAPS.newThing = "new_thing"
// 2. Let new clients advertise it in WS hello
// 3. Keep the shared producer schema strict
// 4. Gate the new emitted value: session.supports(CLIENT_CAPS.newThing) ? "new_value" : "old_value"Agent lifecycle
The lifecycle states are defined in shared/agent-lifecycle.ts:
initializing → idle ⇄ running
↓ ↓ ↓
error
↓
closed- initializing — provider session is being createdidle
- — has a live session, awaiting the next promptrunning
- — provider is currently producing a turnerror
- — last attempt failed; session is still attachedclosed
- — 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 sequence allocation is append-only with epochs (each run starts a new epoch). The one
permitted in-place enrichment adds a provider message id to the manager-owned row for an accepted
prompt; it preserves the row's sequence, content, and timestamp. 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.$PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json
- 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 (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 |checkoutDiffQueryKey(serverId, cwd, mode, baseRef, ws)
| Git diff | | packages/app/src/git/query-keys.ts |checkoutPrStatusQueryKey(serverId, cwd)
| Forge change request | | packages/app/src/git/query-keys.ts |prPaneTimelineQueryKey({ serverId, cwd, prNumber })
| Change request timeline | | packages/app/src/git/pull-request-panel/query-keys.ts |["workspaceFile", serverId, cwd, path]
| File preview content | | packages/app/src/components/file-pane.tsx |listDirectory(workspaceRoot, path)
| File explorer listings | fetched via | 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 |packages/app/src/review/state.ts
| Diff mode override | review-draft scope key (in-memory) | |buildWorkspaceAttachmentScopeKey
| Composer attachments | | packages/app/src/attachments/workspace-attachments-store.ts |fileExplorer
| File explorer nav/open state | map keyed workspace:{workspaceId} | packages/app/src/hooks/use-file-explorer-actions.ts |expandedPathsByWorkspace[workspaceStateKey]
| File explorer expanded paths | | packages/app/src/stores/panel-store/state.ts |
diff-pane.tsx is the canonical wiring site: it passes { serverId, cwd } to the git queries and { serverId, workspaceId, cwd } to the draft/override/attachment scope keys.
Do not "fix" the sharing away. Re-keying a directory-backed query by workspaceId makes same-cwd workspaces diverge (two windows onto the same git tree showing different diffs). Re-keying owned state (drafts, expanded paths) by cwd makes them leak between distinct workspaces on the same folder. The workspaceId-keyed builders carry a // workspaceId is opaque; do not parse this key back into a path. comment — the opaque-id fallback to cwd exists only for old payloads without a workspaceId, not as a content-sharing mechanism.
One deliberate non-violation: AgentFileExplorerState.directories/files cache directory listings inside the workspaceId-keyed explorer map. Same-cwd workspaces therefore keep duplicate caches, but they can never diverge — both fetch the identical directory via listDirectory(workspaceRoot, …). This is duplication, not leakage, and is left as-is.
Agent providers
Each provider implements the AgentClient interface in agent/agent-sdk-types.ts. Provider implementations live in agent/providers/.
The built-in, user-facing providers are Claude Code, Codex, Copilot, OpenCode, 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-app-server
| Codex | Codex AppServer () | ~/.codex/sessions/{date}/rollout-{ts}-{id}.jsonl |acp-agent
| Copilot | GitHub Copilot via ACP | Provider-managed |
| OpenCode | OpenCode server / CLI | Provider-managed |
| Cursor | ACP wrapper () | 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:
- Handle their own authentication (Paseo does not manage API keys)
- Support session resume via persistence handles
- 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)AgentManager.create()
2. Session routes to ManagedAgent
3. AgentManager creates a , initializes provider sessionAgentStreamEvent
4. Provider runs the agent → emits itemsToolCallDetail
5. Events append to the agent timeline, broadcast to all subscribed clients
6. Tool calls are normalized to (shell, read, edit, write, search, etc.)
7. Permission requests flow: agent → server → client → user decision → server → agent
Storage
$PASEO_HOME defaults to ~/.paseo. The most important files:
$PASEO_HOME/
├── agents/{cwd-with-dashes}/{agent-id}.json # Agent record + persisted timeline rows
├── projects/projects.json # Project registry
├── projects/workspaces.json # Workspace registry
├── projects/icons/ # Custom project icon images
├── schedules/ # Scheduled-agent definitions and runs
├── 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
1. Local daemon (default): paseo daemon start on 127.0.0.1:6767paseo daemon start
2. Managed desktop: Electron app spawns daemon as subprocess, and stops it again on quit so that "restart the app" is a complete reset. Settings > Host > "Keep daemon running after quit" opts out. Only a daemon the desktop started is stopped — a daemon you started yourself with is left alone (paseo.pid records desktopManaged).
3. Remote + relay: Daemon behind firewall, relay bridges with E2E encryption
---
Browser Capture Harness
Browser Capture Harness
The desktop capture harness is the real-Electron verification path for browser screenshots.
It validates the compositor behavior that unit tests cannot see:
- the resident automation <webview> starts in the production parking state;capturePage
- the parked guest remains paintable and has a copyable viewport frame;
- a never-presented resident webview guest defaults to 1280x800 logical pixels;
- multiple resident webviews are parked as an overlapping stack without per-capture
stacking changes;
- a newly attached resident webview whose first useful frame is delayed can be captured
by retrying until the frame appears;
- both viewport and full-page CDP screenshots return real pixels from
the permanent production parking state;
- guest background throttling can be disabled once at attach without per-capture
renderer coordination;
- the real-Electron host-composer sentinel proves guest Enter cannot submit a focused
host composer;
- the automation group loads the compiled production keyboard boundary and guest
preload, then proves that initial page window handlers get first refusal, unhandled
shortcuts synchronously suppress editable browser defaults before crossing the host
boundary, shortcuts marked unavailable in editable targets retain the browser field's
native behavior, handlers registered after preload still get first refusal, focused
iframes share the same boundary, digit wildcard shortcuts cross, and background automation
stays in the guest.
Run it with the repo Electron:
npm run capture-harness --workspace=@getpaseo/desktopBuild the desktop main process before the automation group so its production guest
preload is available:
npm run build:main --workspace=@getpaseo/desktop
PASEO_CAPTURE_HARNESS_GROUP=automation npm run capture-harness --workspace=@getpaseo/desktopRun the shared browser profile fixture with:
PASEO_CAPTURE_HARNESS_GROUP=browser-profile npm run capture-harness --workspace=@getpaseo/desktopThe browser profile group runs two Electron processes in sequence. It verifies that each
renderer-side did-attach identity maps to the correct main-process guest, that two live
tabs share cookies and local storage through one persistent session, and that the data is
still present after the first Electron process exits and the second starts.
The automation group uses a real guest webview to verify the page-side ref contract:
ARIA-like snapshot text includes headings, static text, and controls; refs survive
pushState when the element still matches; same-URL rerenders stale old refs; and a
file-input ref can be resolved to a CDP backend node id for upload. It also verifies
page-context evaluation, including passing a resolved ref element as the function argument.
Keyboard containment runs last because the host-composer sentinel intentionally leaves
native focus in the host. It reuses an existing fixture button: adding a test-only control
changes the inline fixture geometry exercised by the earlier actionability checks.
On macOS the harness process must set app.setActivationPolicy("accessory") andshowInactive()
hide the Dock icon before creating any window. only prevents windowshowInactive()
focus; a normal Electron app launch can still activate the app and steal focus.
Harness windows are then created hidden, positioned in a screen corner, skipped from
the taskbar where Electron supports it, and revealed with fromready-to-show. Do not replace this with show(), focus(), or app.focus():
the compositor only needs visible inactive windows, and harness runs must not steal
focus from the person using the machine.
The harness writes PNG evidence and results.json to:
packages/desktop/capture-harness/out/A passing run prints PASS lines for the production P1 attach-off parking state,
including fresh, settled, 75-second soak, multi-tab, viewport, and full-page checks. The
PNG sizes may be device-pixel scaled; on a Retina display the 1280x800 logical viewport
is usually saved as 2560x1600.
Mechanism
Electron captures copy from the guest web contents' compositor surface. A resident
webview parked with display:none, offscreen coordinates, or opacity:0 can lose itsleft:0
copyable surface. Each production webview keeps one permanent body-level surface. Presenting
or parking changes that surface's geometry without reparenting the webview. The parking state
uses , top:0, width:1px, height:1px, overflow:hidden, opacity:1, andpointer-events:none. The webview stays at its resolved logical viewport, defaulting todisplay:inline-flex
1280x800 before first presentation, with at left:0, top:0.
Presentation resolves responsive guests to the pane's exact pixel dimensions after the surface
has visible bounds. Do not apply percentage guest sizing against the parked surface: Electron
exposes the 1x1 parking geometry as a real guest resize before expanding it again.
The permanent browser host and overlay-root are explicit sibling paint planes. The browseroverlay-root
plane stays below the overlay plane regardless of body insertion order; menus keep their relative
layering inside . Activating a presented browser also focuses its registered guestWebContents in main so macOS assigns keyboard first-responder ownership to the page.
There is no renderer prep/restore handshake. Main disables guest background throttling
once when the webview attaches, then screenshot capture uses the shared serialized queue,
invalidates before each attempt, and retries known first-frame failures within the
5-second capture budget. Viewport screenshots use capturePage({ stayHidden:false });
full-page screenshots use the existing CDP path with layout metrics and screenshot clip.
---
Coding Standards
Coding Standards
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.
Core principles
- 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.index.ts
- No barrel files that only re-export — they create indirection and circular-dep risk. Import from the source.
Shell scripts
- Bash scripts always use #!/usr/bin/env bash. Never hard-code /bin/bash or /usr/bin/bash; those paths are not portable to environments such as NixOS.
Comments and noise
- Delete any comment where removing it loses zero information. Comments explain _why_, not _what_.
- No tutorial comments explaining language features (// Use destructuring to...).// ===== Helpers =====
- No decorative section dividers (). Use files and modules to organize, not ASCII art.// might need to revisit
- No hedging comments (, // should work for most cases). If you're unsure, investigate.console.log
- No commented-out code. Git remembers.
- No / debugger left behind. No TODO: implement stubs — if it needs to exist, write it.
Confidence: commit to a shape
- 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.if (!agent) return
- No defensive checks for conditions the type system already rules out ( on a non-nullable parameter).try/catch
- No "just in case." If you can't say what you're catching and why, don't catch.null
- Optionality is a design decision, not a migration shortcut. Distinct valid states → discriminated union. Intentionally empty → explicit . Keep optionality at real boundaries.
Types
- 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.z.infer<typeof schema>
- If a Zod schema exists, the TypeScript type is . Never hand-write a parallel type.Pick
- One canonical type per concept. Layer-specific views are / Omit, not duplicated fields.Array<{ ... }>
- Name multi-property object shapes — no inline or Promise<{ ... }> in signatures, returns, or generic args.string
- Use string literal unions, not raw , when the value is one of a known set. Catches typos at compile time.(thing, true, false, true)
- Object parameters past the obvious-name threshold: 3+ args, any boolean arg, any optional arg → object. is unreadable at the call site.{ isLoading; error?; data? }
- Make impossible states impossible — discriminated unions over bags.
Errors
- 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.instanceof
- Catch blocks branch on for what they can handle; rethrow the rest. No catch (e) { return null }.
- Separate user-facing copy from log/debug strings — don't make one string serve telemetry, logs, and the UI.
- Fail explicitly. If the caller asked for X and X isn't available, throw — don't silently substitute Y.
- Every fallible user action owns explicit pending, success, and failure UI. Console logs and unverified platform alerts do not satisfy this contract. See testing.md.
- A capability advertised to a client means the current runtime can perform the action, not merely that its RPC handler exists. If an unavailable action needs explanatory UI, send the runtime fact or reason separately and keep the server-side refusal fail-closed.
Density
- Nested ternaries are forbidden. A single ternary is fine only when both branches are a single identifier or trivial access (x ? a : b).Object.fromEntries(arr.filter(...).map(...))
- 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 (, Math.max(...xs.map(...))) → break into named intermediates.
- Max 3 levels of nesting (callbacks, JSX, control flow). Above that, extract.
Structure and modules
- 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.-utils
- Filenames ending in , -helpers, -manager, -handler, -controller, -formatter, -builder are a smell — the path didn't carry enough domain.getActiveAgents()
- Boundary returns answer the caller's question (), not "here's my storage" (getAgents().filter(...) repeated everywhere).plan
- 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 (, 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.
Refactoring is a bolt-on test
- 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.
- Migrate all callers and remove old paths in the same refactor. No fallback behavior unless explicitly designed.
React
- useEffect is for synchronizing with external systems (DOM, network, timers, subscriptions). Not for transforming React state. Derived state → compute in render or useMemo.useRef
- No effect cascades — chains of effects setting state that triggers more effects almost always want React Query or a reducer.
- 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.useState
- Server state goes through React Query. Manual + useEffect + isLoading + error for fetched data is always worse.useState
- Components render and dispatch — they don't compute transitions. Two-plus interacting s → extract a reducer.status
- Never define components inside other components. Module-scope only.
- Subscribe narrowly: select primitives from stores, pass not agent, use useShallow / deep-equal when returning derived arrays/objects.useMemo
- Collection rows do not independently subscribe to a high-frequency global store. The collection owner selects structurally shared indexes once, derives a keyed row model with , and passes entries to rows. This keeps retained hidden collections current without running one selector per row on every store update.RetainedPanel
- Equality functions prevent React renders; they do not prevent selector callbacks from running. A selector attached to a hot store must be O(1) when its relevant source references have not changed.
- Retained native panels use . If an existing gesture/layout wrapper must own visibility, wrap its contents in RetainedPanelActivity instead. Keep keyed panel roots in a stable sibling order, include the newly active panel in the same render, centralize subscriptions, and gate genuine effects through useRetainedPanelActive. Do not use Suspense or render freezing for this on native: those techniques change native tree ownership instead of merely stopping work.memo
- Infinite animations are subscriptions. Start them only while their retained panel is active, and cancel a shared clock when its final active consumer leaves. Synchronized animations use one clock per animation family and feed active instances through local shared values; retained hidden instances stay mounted but unsubscribed. Match state updates to the actual visual cadence; do not run every style worklet at 60 fps when the rendered value changes only a few times per second.
- Stable references for props that cross 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.key
- Use stable ids for , never array index for reorderable/filterable lists.
- Context for stable values (theme, auth). Store with selectors for state that changes.
Naming
- Names describe meaning, not mechanics. submitForm over handleOnClickButtonSubmit. running over filteredArrayOfRunningAgents.AgentManager
- The right length is the shortest unambiguous in context. Inside , methods are start, stop, list.getX
- Match the surrounding code's vocabulary. If the codebase uses , don't introduce fetchX / retrieveX for the same shape.getAgent
- Don't leak implementation into names — , not queryPostgresForAgent. If swapping the impl would force a rename, the name is wrong.isX
- Booleans read as yes/no questions: , hasX, canX. Avoid negative booleans (isNotConnected).data
- , result, info, manager, temp are smells — say what the thing _is_.
---
Custom Providers
Custom Provider Configuration
Paseo supports configuring custom agent providers through config.json (located at $PASEO_HOME/config.json, typically ~/.paseo/config.json). You can extend built-in providers with different API backends, add ACP-compatible agents, set custom binaries, disable providers, and create multiple profiles for the same underlying provider.
Provider definitions live under agents.providers in config.json:
{
"version": 1,
"agents": {
"providers": {
"provider-id": { ... }
}
}
}Provider IDs must be lowercase alphanumeric with hyphens (/^[a-z][a-z0-9-]*$/).
Each provider catalog refresh waits up to 2 minutes. If a provider loads many plugins or a large
agent catalog during startup, raise the limit in milliseconds:
{
"agents": {
"catalogRefreshTimeoutMs": 180000
}
}The limit applies independently to every provider refresh and covers availability plus the entire
catalog probe. PASEO_PROVIDER_REFRESH_TIMEOUT_MS sets it when the config field is absent.
---
Table of Contents
- Extending a built-in provider
- Z.AI (Zhipu) coding plan
- Alibaba Cloud (Qwen) coding plan
- Codex with a custom OpenAI-compatible endpoint
- Multiple profiles for the same provider
- Custom binary for a provider
- Disabling a provider
- ACP providers
- Provider override reference
---
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, omp). The new provider gets its own entry in the provider list, with its own label, environment, and model definitions.
{
"agents": {
"providers": {
"my-claude": {
"extends": "claude",
"label": "My Claude",
"description": "Claude with custom API endpoint",
"env": {
"ANTHROPIC_API_KEY": "sk-ant-...",
"ANTHROPIC_BASE_URL": "https://my-proxy.example.com/v1"
}
}
}
}
}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 below for the dedicated Codex example.
---
Z.AI (Zhipu) coding plan
Z.AI is a Chinese AI company (Zhipu AI) that offers an Anthropic-compatible API endpoint. Their GLM Coding Plan provides flat-rate access to GLM models through Claude Code's Anthropic API protocol. These are not Anthropic Claude models — they are Zhipu's own GLM models exposed through an Anthropic-compatible API.
Setup
1. Register at z.ai and subscribe to a coding plan
2. Create an API key from the Z.AI dashboard
3. Add a provider entry in config.json:
{
"agents": {
"providers": {
"zai": {
"extends": "claude",
"label": "ZAI",
"env": {
"ANTHROPIC_AUTH_TOKEN": "<your-zai-api-key>",
"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 },
{ "id": "glm-5.1", "label": "GLM 5.1" }
]
}
}
}
}Available models
| Model | Tier |
| ------------- | ------------------- |
| glm-5.1 | Advanced (flagship) |glm-5-turbo
| | Advanced |glm-4.7
| | Standard |glm-4.5-air
| | Lightweight |
Notes
- ANTHROPIC_AUTH_TOKEN is used instead of ANTHROPIC_API_KEY — this is the z.ai API keyAPI_TIMEOUT_MS
- The env var extends the request timeout (z.ai can be slower than direct Anthropic)/logout
- If you get auth errors, run inside Claude Code before switching to the z.ai providerWebSearch
- Web search ( tool) is an Anthropic-only server-side feature — third-party endpoints don't support it. Add "disallowedTools": ["WebSearch"] to avoid errors.npx @z_ai/coding-helper
- Automated setup is also available:
- Official docs: docs.z.ai/devpack/tool/claude
---
Alibaba Cloud (Qwen) coding plan
Alibaba Cloud Model Studio offers a coding plan that routes Claude Code requests to Qwen models through an Anthropic-compatible API. Like z.ai, these are not Anthropic Claude models.
Setup
1. Go to the Coding Plan page on Alibaba Cloud Model Studio (Singapore region)
2. Subscribe to the Pro plan ($50/month)
3. Obtain your plan-specific API key (format: sk-sp-xxxxx) — this is different from a standard Model Studio key
4. Add a provider entry in config.json:
{
"agents": {
"providers": {
"qwen": {
"extends": "claude",
"label": "Qwen (Alibaba)",
"env": {
"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" },
{ "id": "kimi-k2.5", "label": "Kimi K2.5" }
]
}
}
}
}API endpoints
| Mode | Base URL |
| ------------------------------- | ----------------------------------------------------------- |
| Coding plan (subscription) | https://coding-intl.dashscope.aliyuncs.com/apps/anthropic |https://dashscope-intl.aliyuncs.com/apps/anthropic
| Pay-as-you-go (no subscription) | |
For pay-as-you-go, use ANTHROPIC_API_KEY with a standard Model Studio key (sk-xxxxx) instead of ANTHROPIC_AUTH_TOKEN.
Available models
Recommended for coding plan:
| Model | Notes |
| ------------------ | --------------------------- |
| qwen3.5-plus | Vision capable, recommended |qwen3-coder-next
| | Optimized for coding |kimi-k2.5
| | Vision capable |glm-5
| | Zhipu GLM |MiniMax-M3
| | MiniMax |
Additional models (pay-as-you-go):
qwen3-max, qwen3.5-flash, qwen3-coder-plus, qwen3-coder-flash, qwen3-vl-plus, qwen3-vl-flash
Notes
- API keys must be created in the Singapore region
- The coding plan is for personal use only in interactive coding tools
- Web search (WebSearch tool) is an Anthropic-only server-side feature — third-party endpoints don't support it. Add "disallowedTools": ["WebSearch"] to avoid errors.
- Official docs: alibabacloud.com/help/en/model-studio/claude-code-coding-plan
---
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
{
"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:
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."codex"
- To run multiple endpoints side-by-side, define multiple entries that each extend with different IDs, labels, and env. Each appears as its own provider in the app.OPENAI_BASE_URL
- If you only want to override the binary (e.g. a nightly Codex build) without changing the endpoint, omit and use command instead — see 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.
"Profile" here means a provider alias, and it is not an Agent profile — that is a named bundle of provider, model, mode, thinking option and features, stored under daemon.agentProfiles. See glossary.md for all four senses of the word.
Example: two different Anthropic accounts as separate profiles:
{
"agents": {
"providers": {
"claude-work": {
"extends": "claude",
"label": "Claude (Work)",
"description": "Work Anthropic account",
"env": {
"ANTHROPIC_API_KEY": "sk-ant-work-..."
}
},
"claude-personal": {
"extends": "claude",
"label": "Claude (Personal)",
"description": "Personal Anthropic account",
"env": {
"ANTHROPIC_API_KEY": "sk-ant-personal-..."
}
}
}
}
}Each profile appears as a separate provider in the Paseo app. You can select which one to use when launching an agent.
You can also combine profiles with model overrides to pin specific models per profile:
{
"agents": {
"providers": {
"claude-fast": {
"extends": "claude",
"label": "Claude (Fast)",
"models": [{ "id": "claude-sonnet-4-6", "label": "Sonnet 4.6", "isDefault": true }]
},
"claude-smart": {
"extends": "claude",
"label": "Claude (Smart)",
"models": [{ "id": "claude-opus-4-6", "label": "Opus 4.6", "isDefault": true }]
}
}
}
}---
Custom binary for a provider
Override the command used to launch any provider with the command field. This is an array where the first element is the binary and the rest are arguments.
Override a built-in provider's binary
{
"agents": {
"providers": {
"claude": {
"command": ["/opt/claude-nightly/claude"]
}
}
}
}Use a custom wrapper script
{
"agents": {
"providers": {
"claude": {
"command": ["/usr/local/bin/my-claude-wrapper", "--verbose"]
}
}
}
}Custom binary on a derived provider
{
"agents": {
"providers": {
"my-codex": {
"extends": "codex",
"label": "Codex (Custom Build)",
"command": ["/home/user/codex-dev/target/release/codex"]
}
}
}
}The command array completely replaces the default command for that provider. The binary must exist on the system — Paseo checks for its availability and will mark the provider as unavailable if not found.
OMP profiles and Pi-compatible forks
OMP ships as a first-class built-in provider option. It is disabled by default; enable it with:
{
"agents": {
"providers": {
"omp": { "enabled": true }
}
}
}Custom OMP profiles should extend omp. They inherit the OMP adapter's rpc-ui approvals, native Paseo host tools, provider-managed subagents, and import behavior:
{
"agents": {
"providers": {
"omp-work": {
"extends": "omp",
"label": "Oh My Pi (Work)",
"command": ["omp"],
"env": {
"XDG_CONFIG_HOME": "~/.config/omp-work",
"XDG_STATE_HOME": "~/.local/state/omp-work"
},
"params": {
"sessionDir": "~/.local/state/omp-work/omp/agent/sessions",
"smolModel": "openai/gpt-5-mini",
"slowModel": "anthropic/claude-opus-4-1",
"planModel": "openai/o3"
}
}
}
}
}params.sessionDir is used only for importing sessions that were started outside Paseo. If command or XDG env vars move OMP's state directory, set params.sessionDir to the resulting OMP JSONL session directory; launching and resuming still go through the configured command.
For other providers that keep Pi's --mode rpc API but write sessions somewhere else, extend pi, replace the command, and provide the JSONL session directory:
{
"agents": {
"providers": {
"my-pi-fork": {
"extends": "pi",
"label": "My Pi Fork",
"command": ["my-pi-fork"],
"params": {
"sessionDir": "~/.my-pi-fork/sessions"
}
}
}
}
}This session directory is also import-only. Launching and resuming still go through the configured command, so this example resumes with my-pi-fork --mode rpc --session <session-file>.
---
Disabling a provider
Set enabled: false to hide a provider from the provider list. The provider will not appear in the app or CLI.
{
"agents": {
"providers": {
"copilot": { "enabled": false },
"codex": { "enabled": false }
}
}
}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.
---
ACP providers
The Agent Client Protocol (ACP) is an open standard for communication between editors and AI coding agents — think LSP but for AI agents. Any agent that supports ACP can be added to Paseo as a custom provider.
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:
{
"agents": {
"providers": {
"my-agent": {
"extends": "acp",
"label": "My Agent",
"command": ["my-agent-binary", "--acp"],
"env": {
"MY_API_KEY": "..."
}
}
}
}
}Required fields for ACP providers:
- extends: "acp"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:
{
"agents": {
"providers": {
"my-agent": {
"extends": "acp",
"label": "My Agent",
"command": ["my-agent", "acp"],
"params": {
"supportsMcpServers": false
}
}
}
}
}ACP agents execute filesystem and terminal operations in their own environment
by default. To let a compliant agent delegate those operations to Paseo instead,
enable the corresponding client capabilities:
{
"agents": {
"providers": {
"local-agent": {
"extends": "acp",
"label": "Local Agent",
"command": ["local-agent", "acp"],
"params": {
"clientCapabilities": {
"fs": {
"readTextFile": true,
"writeTextFile": true
},
"terminal": true
}
}
}
}
}
}Only enable capabilities Paseo should execute. When the agent and Paseo run in
different environments, configure equivalent absolute workspace paths before
delegating filesystem or terminal operations to Paseo.
Generic ACP diagnostics
Paseo diagnostics for extends: "acp" providers report the configured command, resolved launcher binary, version output, ACP initialize, ACP session/new, model count, modes, and final status.
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 supports ACP via the --acp flag.
1. Install: npm install @google/gemini-cli or see Gemini CLI docs
2. Authenticate with Google (Gemini CLI handles its own auth)
3. Add to config.json:
{
"agents": {
"providers": {
"gemini": {
"extends": "acp",
"label": "Google Gemini",
"command": ["gemini", "--acp"]
}
}
}
}Example: Hermes (Nous Research)
Hermes is an open-source coding agent by Nous Research with persistent memory and multi-provider LLM support. It supports ACP via the acp subcommand.
1. Install: curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bashpip install -e '.[acp]'
2. Install ACP support: ~/.hermes/
3. Configure Hermes credentials in
4. Add to config.json:
{
"agents": {
"providers": {
"hermes": {
"extends": "acp",
"label": "Hermes",
"description": "Nous Research self-improving AI agent",
"command": ["hermes", "acp"]
}
}
}
}Ref: Hermes ACP docs
How ACP providers work in Paseo
When you launch an agent with an ACP provider:
1. Paseo spawns the process using the configured commandinitialize
2. Sends an JSON-RPC request over stdin
3. The agent responds with its capabilities, available modes, and models
4. Paseo creates a session and sends prompts through the ACP protocol
5. The agent streams responses, tool calls, and permission requests back over stdout
Every ACP provider exposes an Auto Accept toggle. Enable it per session to let Paseo approve
ACP permission requests without surfacing each prompt. If the provider sends no allow option,
Paseo leaves the request for you to answer. Unattended agents enable Auto Accept unless you
explicitly disable it.
Models and modes are discovered dynamically at runtime from the agent process. If you want to override the model list (e.g., to curate which models appear in the UI), use the models field:
{
"agents": {
"providers": {
"my-agent": {
"extends": "acp",
"label": "My Agent",
"command": ["my-agent", "--acp"],
"models": [
{ "id": "fast-model", "label": "Fast", "isDefault": true },
{ "id": "smart-model", "label": "Smart" }
]
}
}
}
}Profile models (defined in config.json) completely replace runtime-discovered models when present.
If you want to keep runtime-discovered models and add or relabel a few entries, use additionalModels instead.
Example: add an experimental model while keeping every model the provider discovers at runtime:
{
"agents": {
"providers": {
"my-agent": {
"extends": "acp",
"label": "My Agent",
"command": ["my-agent", "--acp"],
"additionalModels": [
{ "id": "experimental-model", "label": "Experimental", "isDefault": true }
]
}
}
}
}Example: relabel a discovered model without replacing the full list:
{
"agents": {
"providers": {
"my-agent": {
"extends": "acp",
"label": "My Agent",
"command": ["my-agent", "--acp"],
"additionalModels": [{ "id": "provider/model-id", "label": "My Preferred Label" }]
}
}
}
}When an additionalModels entry has the same id as a discovered model, it updates that model in place.
---
Provider override reference
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 |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
Each entry in the models array:
| Field | Type | Required | Description |
| ----------------- | ------------------ | -------- | ------------------------------------- |
| id | string | Yes | Model identifier sent to the provider |label
| | string | Yes | Display name in the UI |description
| | string | No | Short description |isDefault
| | boolean | No | Mark as the default model selection |thinkingOptions
| | ThinkingOption[] | No | Available thinking/reasoning levels |
Thinking option
| Field | Type | Required | Description |
| ------------- | --------- | -------- | ----------------------------------- |
| id | string | Yes | Thinking option identifier |label
| | string | Yes | Display name |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. Explicit model IDs are passed unchanged to Claude Code, even when the same string is a compatibility alias for a built-in model. When agents.providers.claude.models is set it replaces both the hardcoded first-party Claude list and any settings.json-discovered entries; use agents.providers.claude.additionalModels to keep the first-party list and append curated entries on top.
Gotcha: extends: "claude" with third-party endpoints
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.
Use disallowedTools to disable unsupported tools:
{
"agents": {
"providers": {
"my-proxy": {
"extends": "claude",
"label": "My Proxy",
"env": {
"ANTHROPIC_BASE_URL": "https://my-proxy.example.com/v1"
},
"disallowedTools": ["WebSearch"]
}
}
}
}Valid extends values
Built-in providers: claude, codex, copilot, opencode, pi, omp
Special value: acp — creates a generic ACP provider (requires command)
Full example
A config.json with multiple custom providers:
{
"version": 1,
"agents": {
"providers": {
"copilot": { "enabled": false }, "zai": {
"extends": "claude",
"label": "ZAI",
"env": {
"ANTHROPIC_AUTH_TOKEN": "<zai-api-key>",
"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 },
{ "id": "glm-5.1", "label": "GLM 5.1" }
]
},
"qwen": {
"extends": "claude",
"label": "Qwen (Alibaba)",
"env": {
"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" }
]
},
"gemini": {
"extends": "acp",
"label": "Google Gemini",
"command": ["gemini", "--acp"]
},
"hermes": {
"extends": "acp",
"label": "Hermes",
"command": ["hermes", "acp"]
}
}
}
}
---
Data Model
Data Model
Project identity
Projects are allocated for the exact root selected by the caller, normalized lexically with path.resolve (never realpath). New project IDs are opaque prj_<16 hex> values. Existing remote-shaped or path-shaped IDs are retained as readable compatibility records and are never rekeyed. An active exact root is idempotent; archived-only matches do not resurrect an old project. Workspace projectId is stable membership: reconciliation may update git-derived kind and branch metadata, but never rehomes a workspace or changes a project's root, ID, or default name.
projectKey is a persisted, opaque equivalence key used only to group the same logical projectprojectId
across hosts. It is separate from the host-local ; today's producer prefers a normalized
Git remote and otherwise uses the local project root. Consumers never derive it from live Git.
Creation persists it with the project, and normal boot reconciliation fills it for
older records where the field is absent—there is no migration.
kind and projectKey are mutable metadata, not identity. Workspace reconciliation watches active project roots andupdatedAt
updates those fields and when Git facts change, preserving the project's ID, root path,
names, and workspace foreign keys. Attached workspaces are independently refreshed
from their own cwd, so an explicit project root never implies a workspace checkout. Empty projects
are observed too.
The workspace registry model defines placement once: initial directory/worktree construction,
mutable reconciliation fields, and the persisted-to-wire checkout projection. Its update policy
preserves displayName and baseBranch. WorkspaceProvisioningService owns the correspondingcwd
registry writes, so directory opens, agent imports, and worktree creation all enter through that
service instead of constructing records independently. The workspace record is then the durable
placement authority: is the exact execution directory, while worktreeRoot is the backingprojectId
checkout root. They intentionally differ for an exact subproject inside a worktree. Archive,
restore, branch auto-name, and descriptor flows consume those persisted facts rather than
rediscovering ownership from a directory that may already be gone. Reconciliation may refresh
mutable placement facts, but never changes , cwd, displayName, or baseBranch.cwd
Workspace archive runs lifecycle teardown from the exact but removes only the backingworktreeRoot after its last active reference disappears. Worktree recovery recreates that backingmainRepoRoot
checkout from , then restores the relative path from worktreeRoot to cwd.
Paseo uses file-based JSON persistence instead of a traditional database. All data is validated at runtime with Zod schemas. Most stores write atomically (write to temp file, then rename); a few still use plain writeFile — see each section. There is no schema-versioning/migration framework — schemas rely on optional fields with defaults for forward compatibility, with a small amount of inline normalization in persisted-config.ts for legacy provider/speech entries.
All server-side stores live under $PASEO_HOME (defaults to ~/.paseo).
Store Surface Rules
Store APIs own persistence atomicity and should not make services coordinate raw reads and writes. A good store method maps cleanly to one SQL statement or one SQL transaction, even when the current implementation is JSON files. If a caller needs a queue, lock, read-merge-write loop, or uniqueness race workaround, that behavior belongs behind the store surface.
---
Directory layout
$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/
│ └── {sanitized-cwd}/
│ └── {agentId}.json # One file per agent
├── schedules/
│ └── {scheduleId}.json # One file per schedule
├── projects/
│ ├── projects.json # Project registry
│ ├── workspaces.json # Workspace registry
│ └── icons/ # Host-local custom project icon images
├── runtime/
│ └── managed-processes/
│ └── {recordId}.json # Helper processes owned by Paseo; reconciled on daemon bootstrap
└── push-tokens.json # Expo push notification tokensThe 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
Path: $PASEO_HOME/agents/{project-dir}/{agentId}.json
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 |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 uses paseo.parent-agent-id for parentage and client-scoped paseo.open-agent-tab.* labels while managed subagent tabs are open — see agent-lifecycle.md |lastStatus
| | AgentStatus | One of: "initializing", "idle", "running", "error", "closed". closed means the record is resumable but has no live provider runtime; archive remains represented separately by archivedAt. |lastModeId
| | string? | Last active mode ID |config
| | SerializableConfig? | Agent session configuration (see below) |runtimeInfo
| | RuntimeInfo? | Live runtime state (see below) |features
| | AgentFeature[]? | Provider-reported features (toggles/selects) |persistence
| | PersistenceHandle? | Handle for resuming sessions |lastError
| | string? (nullable) | Last error message, if any |requiresAttention
| | boolean? | Whether the agent needs user attention |attentionReason
| | "finished" \| "error" \| "permission"? | Why attention is needed |attentionTimestamp
| | string? (ISO 8601) | When attention was flagged |internal
| | boolean? | Whether this is a system-internal agent |archivedAt
| | string? (ISO 8601) | Soft-delete timestamp |
Nested: SerializableConfig
| Field | Type | Description |
| ------------------ | -------------------------- | ---------------------------- |
| title | string? | Configured title |modeId
| | string? | Configured mode |model
| | string? | Configured model |thinkingOptionId
| | string? | Thinking/reasoning level |featureValues
| | Record<string, unknown>? | Feature preference overrides |extra
| | Record<string, any>? | Provider-specific config |systemPrompt
| | string? | Custom system prompt |mcpServers
| | Record<string, any>? | MCP server configurations |
Nested: RuntimeInfo
| Field | Type | Description |
| ------------------ | -------------------------- | ------------------------------ |
| provider | string | Active provider |sessionId
| | string? | Active session ID |model
| | string? | Active model |thinkingOptionId
| | string? | Active thinking option |modeId
| | string? | Active mode |extra
| | Record<string, unknown>? | Provider-specific runtime data |
Nested: PersistenceHandle
| Field | Type | Description |
| -------------- | ---------------------- | --------------------------------------------------------------------- |
| provider | string | Provider that owns the session |sessionId
| | string | Session ID for resumption |nativeHandle
| | any? | Provider-specific handle (Codex thread ID, Claude resume token, etc.) |metadata
| | Record<string, any>? | Extra metadata |
Nested: AgentFeature (discriminated union on type)
Toggle:
| Field | Type |
| ------------- | ---------- |
| type | "toggle" |id
| | string |label
| | string |description
| | string? |tooltip
| | string? |icon
| | string? |value
| | boolean |
Select:
| Field | Type |
| ------------- | --------------------- |
| type | "select" |id
| | string |label
| | string |description
| | string? |tooltip
| | string? |icon
| | 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
Single file, validated with PersistedConfigSchema.
paseo reload reads and validates this file once inside the daemon. That snapshot drives resolution,DaemonConfigStore
classification, application, and reload bookkeeping. owns applying runtime-safe
fields and their removal/default semantics; session handlers and the CLI only relay the structured
result. Normal config patches persist only the requested fields, so launch overrides and resolved
defaults never leak into the file. Startup-only fields remain compared with the daemon's launch
snapshot so a mixed edit can apply its live subset and still name the paths that require restart.
/ Detailed source-code truncated for AI context efficiency. /All fields are optional with sensible defaults.
Profile lists
terminalProfiles and agentProfiles are both whole-list fields: a config patch replaces the
array, never merges entries, so a client sends the complete next list on every add, edit, reorder
and remove. List order is the display order.
Absent and empty mean different things for terminal profiles — omitting the key falls back to
DEFAULT_TERMINAL_PROFILES, while [] means the user removed them all. Agent profiles have no
defaults, so both mean none.
PersistedConfigSchema parses strictly, so a daemon that predates a field drops it on writeserver_info.features.agentProfiles
rather than storing something it cannot describe. That is why the client gates the agent profiles
UI on instead of letting a save appear to succeed against an
older daemon.
Git process limits
Git process limits are global to one daemon. The start-rate limit defaults to 64 processes per8
second, and the concurrency limit defaults to :
{
"daemon": {
"git": {
"maxProcessesPerSecond": 64,
"maxProcessConcurrency": 8
}
}
}maxProcessesPerSecond limits Git process starts in any one-second interval. The allowance canmaxProcessConcurrency
start as a burst; it does not wait for earlier processes to exit. limits
the number of Git processes that have started but not exited. Every Git command uses both limits,
including initial workspace reads, filesystem-triggered refreshes, background checks, and explicit
requests.
Environment variables override config.json:
| Environment variable | Setting |
| ------------------------------------ | ------------------------ |
| PASEO_GIT_MAX_PROCESSES_PER_SECOND | maxProcessesPerSecond |PASEO_GIT_MAX_PROCESS_CONCURRENCY
| | maxProcessConcurrency |PASEO_GIT_CONCURRENCY
| | Legacy concurrency alias |
PASEO_GIT_MAX_PROCESS_CONCURRENCY wins when it and the legacy alias are both set. Run paseo reloadconfig.json
after changing . Environment changes require a daemon restart; the launch environment
remains authoritative during reload.
agents.metadataGeneration.providers controls the preferred structured-generation fallback order for daemon-side metadata tasks such as commit messages, PR text, branch names, and generated agent titles. Entries are tried first in the configured order, then Paseo falls through to dynamically discovered defaults and finally the current selection when available.
Local speech model ids are intentionally narrow: STT uses parakeet-tdt-0.6b-v2-int8, TTS uses kokoro-en-v0_19, and turn detection uses the bundled Silero VAD model.
Set these to select OpenAI instead of local speech:
| Env var | Applies to |
| ------------------------------ | ------------------------------- |
| PASEO_VOICE_STT_PROVIDER | Voice mode STT provider |PASEO_DICTATION_STT_PROVIDER
| | Composer dictation STT provider |PASEO_VOICE_TTS_PROVIDER
| | Voice mode TTS provider |
OpenAI speech can be configured under providers.openai. STT and TTS resolve independently, so they can point at different endpoints:
{
"providers": {
"openai": {
"stt": {
"apiKey": "sk-...",
"baseUrl": "https://stt.example.com/v1"
},
"tts": {
"apiKey": "sk-...",
"baseUrl": "https://api.openai.com/v1"
}
}
}
}providers.openai.stt is used for both composer dictation and voice mode speech-to-text; providers.openai.tts is used for voice mode text-to-speech. The equivalent env vars are OPENAI_STT_API_KEY/OPENAI_STT_BASE_URL and OPENAI_TTS_API_KEY/OPENAI_TTS_BASE_URL. Each feature falls back to providers.openai.apiKey/providers.openai.baseUrl, then OPENAI_API_KEY/OPENAI_BASE_URL, when its own fields are unset. These settings apply only to Paseo OpenAI speech features, not to Codex or other OpenAI-backed tools.
Paseo uses these paths under the configured OpenAI base URL:
- dictation STT: /v1/audio/transcriptions/v1/audio/transcriptions
- voice mode STT: /v1/audio/speech
- voice mode TTS:
---
3. Schedule
Path: $PASEO_HOME/schedules/{id}.json
One file per schedule. ID is 8 hex characters.
| Field | Type | Description |
| ----------- | ------------------------------------- | -------------------------------- |
| id | string | 8-char hex ID |name
| | string? | Human-readable name |prompt
| | string | The prompt to send |cadence
| | ScheduleCadence | Timing (see below) |target
| | ScheduleTarget | What to run (see below) |status
| | "active" \| "paused" \| "completed" | Current state |createdAt
| | string (ISO 8601) | |updatedAt
| | string (ISO 8601) | |nextRunAt
| | string? (ISO 8601) | Next scheduled execution |lastRunAt
| | string? (ISO 8601) | Last execution time |pausedAt
| | string? (ISO 8601) | When paused |expiresAt
| | string? (ISO 8601) | Auto-expire time |maxRuns
| | number? | Max executions before completing |runs
| | ScheduleRun[] | Execution history |
Nested: ScheduleCadence (discriminated union on type)
- { type: "cron", expression: string, timezone?: string } — canonical cadence for new writes; absent timezone means UTC{ type: "every", everyMs: number }
- — legacy rolling interval, still readable and executable during the compatibility window
Nested: ScheduleTarget (discriminated union on type)
- { type: "agent", agentId: string } — send to existing agent{ type: "new-agent", config: { provider, cwd, modeId?, model?, thinkingOptionId?, title?, providerOptions?, featureValues?, systemPrompt?, mcpServers? } }
- — create a new agent
Nested: ScheduleRun
| Field | Type | Description |
| -------------- | -------------------------------------- | ----------------------- |
| id | string | Run ID |scheduledFor
| | string (ISO 8601) | Intended execution time |startedAt
| | string (ISO 8601) | |endedAt
| | string? (ISO 8601) | |status
| | "running" \| "succeeded" \| "failed" | |agentId
| | string? (UUID) | Agent used for this run |output
| | string? | Agent output text |error
| | string? | Error message if failed |
---
4. Project Registry
Path: $PASEO_HOME/projects/projects.json
Array of project records.
| Field | Type | Description |
| -------------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| projectId | string | Host-local primary key; new records use opaque prj_<16 hex> IDs |projectKey
| | string \| null | Persisted opaque cross-host grouping key; reconciliation backfills absent values |rootPath
| | string | Exact lexically normalized selected root; never realpathed |kind
| | "git" \| "non_git" | Mutable Git observation about rootPath, never a membership key |displayName
| | string | Selected-root basename, stable across remote and Git changes |customName
| | string \| null | User-set override layered over displayName. Null means "use the derived name". |customIconRevision
| | string \| null | Identifies the host-local custom icon stored under projects/icons/. Null means the icon is discovered by scanning the project directory. |createdAt
| | string (ISO 8601) | |updatedAt
| | string (ISO 8601) | |archivedAt
| | string \| null (ISO 8601) | Soft-delete timestamp; required nullable |
Uploading a file and pasting a website or image URL are two ways of _acquiring_ the same custom
icon. The client fetches URL imports and sends their bytes through the upload RPC. The daemon never
receives or fetches the URL; it validates the uploaded bytes, stores them, and records a new
customIconRevision. Going back to automatic deletes the stored image, as does removing the
project.
Active exact roots are idempotent using lexical platform-equivalence semantics. Existing legacy
remote-shaped and path-shaped IDs remain readable, including duplicate roots; reconciliation never
merges them, transfers names, archives them, or moves workspace foreign keys. An explicit
workspace projectId is authoritative when it names an active project, regardless of cwd
containment. Archived-only exact-root records are not resurrected by explicit add/open; a fresh
opaque project is allocated instead. Agent restore is separate and restores the agent's existing
workspace together with its owning project.
---
5. Workspace Registry
Path: $PASEO_HOME/projects/workspaces.json
Array of workspace records. A workspace is a specific working directory within a project.
| Field | Type | Description |
| ------------------------------ | ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| workspaceId | string | Opaque stable identifier (wks_<hex>), generated independently of the directory. MUST NOT be treated as a path; compare by exact equality. Use the cwd field for directory access. |projectId
| | string | FK to Project.projectId; the workspace's stable project membership |cwd
| | string | Exact execution directory selected for agents, files, scripts, and setup |kind
| | "local_checkout" \| "worktree" \| "directory" | Mutable checkout classification |displayName
| | string | The human name (the generated/derived title). Decoupled from branch by construction. |title
| | string \| null | User-set name override layered over displayName. Null means "use displayName". |branch
| | string \| null | The current Git branch for git-backed workspaces. Separate from displayName/title; a background branch refresh never rewrites the name. |worktreeRoot
| | string \| null | Backing checkout/worktree root. May differ from cwd for exact subprojects and remains persisted after the worktree is deleted so restore can reproduce the placement. |baseBranch
| | string \| null | Normalized branch the Paseo worktree was created from; null for directories, local checkouts, and checkout-branch worktrees |isPaseoOwnedWorktree
| | boolean | Whether Paseo owns and may remove/recreate the backing worktreeRoot |mainRepoRoot
| | string \| null | Main repository root for worktree checkouts, independent of both exact cwd and backing worktreeRoot |createdAt
| | string (ISO 8601) | |updatedAt
| | string (ISO 8601) | |archivedAt
| | string \| null (ISO 8601) | Soft-delete; required nullable |autoArchivedChangeRequestUrl
| | string \| null | Change request whose merged state triggered auto-archive. Restore replaces it with the current merged change request, when present, so repeated snapshots cannot archive the workspace again. |pinnedAt
| | string \| null (ISO 8601) | Pinned-to-top-of-sidebar timestamp; null means "not pinned" |
Opaque-ID invariant: workspaceIdis opaque identity, never a filesystem path. Filesystem and git operations takecwd/workspaceDirectoryonly — never the id. A compatibility-only first-materialization bootstrap still groups pre-registry agent records by path and Git remote so existing installs retain their legacy records. That grouping never runs against a live registry, and its keys are not runtime project or workspace identity.
projectId is still a real FK: workspace records should have a matching project record. Read-only
history surfaces tolerate transient orphaned workspaces by omitting those rows so one bad FK cannot
blank the whole History screen, but mutation paths should repair or remove the orphaned state rather
than treating it as valid.
---
6. Push Token Store
Path: $PASEO_HOME/push-tokens.json
{
"tokens": ["ExponentPushToken[...]", ...]
}Simple set of Expo push notification tokens. Loaded with permissive parsing (filters non-string entries). Persisted with atomic temp-file rename.
---
7. Daemon meta files
These small files are not validated as full Zod schemas but are persisted under $PASEO_HOME for daemon identity and runtime coordination.
| 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. |
---
Client-side stores (App)
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 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.workspaceId
- Workspace-owned (independent per workspace): keyed by , 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)
{
drafts: Record<draftKey, {
input: { text: string, images: AttachmentMetadata[] },
lifecycle: "active" | "abandoned" | "sent",
updatedAt: number, // epoch ms
version: number // optimistic concurrency
}>,
createModalDraft: DraftRecord | null
}Attachment Store (Web)
IndexedDB database: paseo-attachment-bytes, object store: attachments
Stores binary attachment blobs keyed by attachment ID.
AttachmentMetadata
| Field | Type | Description |
| ------------- | --------- | ------------------------------ |
| id | string | Unique attachment ID |mimeType
| | string | MIME type |storageType
| | string | Storage backend identifier |storageKey
| | string | Key within the storage backend |createdAt
| | number | Epoch ms |fileName
| | string? | Original filename |byteSize
| | number? | Size in bytes |
---
Design
Design
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), 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).fontWeight.medium
- Structural labels use . 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).fontWeight.normal
- Content uses . 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.
foregroundExtraMuted is reserved for passive chrome that must sit behind muted text, such as an always-visible window control. Use the solid token instead of lowering SVG opacity; per-path opacity makes overlapping icon strokes render unevenly. Interactive hover and pressed states return to foreground.
Accent is the one CTA per surface. A <Button variant="default"> filled with accent appears at most once on a page. Most pages have zero — settings is mostly toggles and text, the workspace pane is mostly content, the chat composer is the input itself.
Destructive is a color, not a click. Restart-daemon and remove-host are <Button variant="outline"> in the row trailing slot; the destructive surface only appears inside the confirmDialog (packages/app/src/screens/settings/host-page.tsx:541-547). Workspace archive opens a confirm dialog before any red appears (packages/app/src/components/sidebar-workspace-list.tsx). Red appears after the user has indicated intent.
---
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: xs for ultra-tight inline triggers. sm for any button sitting in a row. md is the page default. lg is reserved for large standalone CTAs.
Sizes are a shared contract across control kinds, defined once in control-geometry.ts: xs = 28px tall with fontSize.xs labels, sm = 32px with fontSize.sm, md/lg = 44px with fontSize.sm. <SegmentedControl> (packages/app/src/components/ui/segmented-control.tsx) takes the same xs/sm/md sizes — a segmented control next to a <Button> of the same size always matches in height, label size, and horizontal padding. Thin chrome such as the file toolbar uses xs; settings rows use sm. Never shrink a control's font or padding locally to fit a context — if the context needs a smaller control, the size tier is missing or the wrong one is in use.
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 the presentation. Its content inset — the gutter that puts sheet content on the same rails as the sheet header — and compact bottom safe-area padding are the sheet's, not the caller's. A caller declares layout intent through contentStyle and never branches on form factor to add its own margins. 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, 8–12px 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. Alignment
Things align to their glyphs, not to their boxes. A row's leading icon, its title, and the label of the button in its trailing slot sit on the same rails — the ink lines up, not the padding, not the touch target, not the hover background.
Pick the rails from the content, then hold them. A settings card establishes a leading rail at the icon's left edge and a trailing rail at the last glyph's right edge; every row in that card uses the same two. A row whose icon is absent still starts its title on the leading rail. Indentation is a new rail, not an arbitrary offset.
The pressable is bigger than the glyph, and that is fine. Hit areas grow outward from the aligned content — they never move it. A button that looks two pixels off because its padding is asymmetric is misaligned even though its box is correct.
Optical alignment beats arithmetic when a glyph disagrees with its bounding box. Icons with visual weight on one side, chevrons, and single-character labels usually need a small nudge to look centered. Trust the eye, then leave a comment saying the offset is optical.
One row off the rail makes the whole card look unconsidered.
---
9. 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.surfaceSidebar
- On desktop: a 320px sidebar on the left holds the list with 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.
On a narrow desktop route, app navigation yields to the rendered content topology when the remaining width cannot preserve its center target: Settings keeps its 320px list + 400px detail split, and a workspace Explorer keeps its current visible width plus a 400px center pane. That is a topology decision at the app container, not a second compact breakpoint. Temporary width clamps are render-only; widening restores the user's saved sidebar widths.
Electron window controls are top-corner obstructions, not a compact-layout condition. Rendered surfaces declare which top corners they physically occupy; only those corners receive clearance. Full-window overlays redeclare both corners. A focused split pane owns both corners; if focus restoration temporarily exposes the full split tree, the split boundary reserves one top strip instead of assigning a control rectangle to an arbitrarily narrow leaf. The 720px desktop breakpoint preserves the default 320px sidebar and target 400px center width when the Explorer is closed; it is product policy, not an obstruction gate.
A new list+detail feature copies the settings shell. A new workspace-shaped feature copies the workspace shell. Inventing a third shape happens in design review, not in a PR.
---
10. 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.
---
11. 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.
Changing state must not move the layout. A row that grows when its badge arrives, a card that reflows when a count resolves, a list that jumps as data streams in — all wrong. Reserve the space the loaded state will need, so the skeleton, the spinner, and the content occupy the same box. A surface that shifts under the user stops feeling calm.
---
12. 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).
---
13. Status pills and badges
There is exactly one token per status signal — statusSuccess, statusDanger, statusWarning, statusMerged — and every status surface uses it: PR state icons, CI check icons and pies, diff stats, file-change icons, status pills, usage bars. A surface does not get a quieter or louder variant because of where it sits. If a dense list feels loud, that is a density or weight problem; fix the density, not the color. The tokens are generated, not hand-picked — see the rule in packages/app/src/styles/theme.ts and regenerate rather than nudging one value. The level is set by the densest consumer, the sidebar workspace list.
Status dots are the one exception, and they are a family of their own — statusDotSuccess, statusDotDanger, statusDotWarning, statusDotRunning, read only by getStatusDotColor (packages/app/src/utils/status-dot-color.ts). Same hues and the same generation rule, but their own band: 90% of gamut chroma against the status family's 55–60%. A dot is a few points of solid color with no shape to read and no label attached, and the running one pulses, so at the status band's chroma the dots read dimmer than the metadata beside them — backwards, since the dot is the row's state. Lightness is set by hue separation rather than by distance from the surface: at 6pt four dark hues on a light surface all read as one dark blob no matter how much contrast they have. So the light band runs as bright as the contrast floor allows at L=0.62, the last step where all four clear 3:1 against the sidebar's surface2; the dark band sits at L=0.72, where danger turns pink above. All four move together; regenerate the set, never one hue.
Status pills are the status token as foreground on a 10%-alpha tint of the same token (${token}1a), with a 20% border (${token}33). The <StatusBadge> primitive (packages/app/src/components/ui/status-badge.tsx) is canonical; a pill never reaches into palette.
Status dots — the small filled circles next to a host or agent name — are borderRadius.full filled with the status token. Which token a given agent state maps to is owned by getStatusDotColor (packages/app/src/utils/status-dot-color.ts); a row, a group header, and a project icon all call it rather than restating the mapping. They sit in the trailing slot of a sidebar row or as a leading marker on a status pill.
Identity badges — the project icon, the sidebar host badge, and the PR-panel participant avatar — do not use the theme palette. They draw from the fixed ten-color identity table in packages/app/src/styles/identity-colors.ts, whose hexes are held to one contrast band so a color identifies rather than ranks. Project icons and PR avatars use it as a fill with a white letter — that is identityColor, one theme-independent hex per identity. Host badges use it as a _foreground_ on both the glyph and the label, which is a different contrast problem that the fill table cannot solve: no single hex clears 4.5:1 against both a near-white and a dark sidebar. Foregrounds therefore come from identityForeground(name, colorScheme), one set per scheme, hue unchanged. That set is generated on the status family's lightness and chroma fraction, because a meta row puts a host badge beside a CI check and a diff stat, and two families at different lightness make the brighter one shout. Change the status band and this one changes with it. A host with no color assigned falls back to foregroundMuted. The table is theme-independent by design; do not fork it per theme, and do not add hexes to it without recomputing the band.
The bespoke pills in packages/app/src/screens/settings/host-page.tsx:97-116, packages/app/src/components/agent-list.tsx:607-632, and packages/app/src/components/sidebar-workspace-list.tsx:2889-2894 are drift to be removed. New code uses <StatusBadge>.
---
14. 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.<Text>
- Bare for a section header inside settings. <SettingsSection> exists.foregroundMuted
- A "Settings" CTA on a detail page. Detail pages are settings; settings is reached from the sidebar, the host entry, or a row's kebab menu.
- The word "checkout" in UI strings or identifiers. The term is "workspace".
- New color tokens or hardcoded hex outside the palette. Status pill rgba backgrounds and the identity color table are the documented exceptions (§13), not a license.
- Placeholder text dimmed beyond . 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.isWeb
- Raw DOM APIs without an guard.padding: 20
- Spacing values outside the scale. and gap: 10 are wrong.confirmDialog
- Color changes for disabled state. Opacity only.
- Destructive actions without . Restart, remove, and future destructive actions are confirmed. Archive workspace is confirmed only when its worktree backing reports uncommitted changes or unpushed commits; otherwise it archives immediately.<StatusBadge>
- Bespoke status pills. is the pill primitive.Modal
- Raw for a focused task. <AdaptiveModalSheet> is the modal primitive.ActivityIndicator
- Importing directly. <LoadingSpinner> is the loading primitive.
---
15. 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 |packages/app/src/screens/settings/host-page.tsx
| Detail card+row | , packages/app/src/screens/settings/providers-section.tsx |packages/app/src/screens/settings/settings-section.tsx
| Section grouping inside a card list | |packages/app/src/components/add-host-modal.tsx
| Form modal (label + input fields, primary + cancel) | , packages/app/src/components/pair-link-modal.tsx, packages/app/src/components/project-picker-modal.tsx |confirmDialog
| Destructive confirmation | invoked from packages/app/src/screens/settings/host-page.tsx:541-547 |packages/app/src/components/welcome-screen.tsx
| Centered hero / first-run | |packages/app/src/components/sidebar-workspace-list.tsx
| Sidebar list (workspaces, hosts) | , packages/app/src/components/left-sidebar.tsx |packages/app/src/components/agent-list.tsx
| Live list of items with sections (agents) | |packages/app/src/screens/sessions-screen.tsx
| Historical list (sessions) | |packages/app/src/screens/workspace/workspace-screen.tsx
| Workspace pane (multi-tab, split) | |packages/app/src/components/composer.tsx
| Composer / message input | , packages/app/src/components/message-input.tsx |packages/app/src/components/git-diff-pane.tsx
| Pane chrome with single bottom border | , packages/app/src/components/file-explorer-pane.tsx, packages/app/src/components/terminal-pane.tsx |packages/app/src/components/ui/alert.tsx
| Page-level alert (info / success / warning / error) | , packages/app/src/screens/project-settings-screen.tsx |packages/app/src/components/sidebar-callout.tsx
| Sidebar callout (cross-cutting alert) | , 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 |packages/app/src/components/ui/combobox.tsx
| Searchable picker | , packages/app/src/components/branch-switcher.tsx |packages/app/src/components/ui/dropdown-menu.tsx
| Trigger-anchored menu | (used in sidebar-workspace-list.tsx, theme picker) |packages/app/src/components/ui/context-menu.tsx
| Right-click / long-press menu | (used in sidebar-workspace-list.tsx) |packages/app/src/components/headers/back-header.tsx
| Headers (back, screen, menu) | , screen-header.tsx, menu-header.tsx |
---
Development
Development
Prerequisites
- Node.js (see .tool-versions for exact version)
- npm workspaces (comes with Node)
Running the dev server
npm run dev:server
npm run dev:app
npm run dev:desktopRoot checkout dev is intentionally split across terminals:
- npm run dev:server runs the daemon on 127.0.0.1:6768.npm run dev:app
- runs Expo on http://localhost:8081 and connects to the dev daemon.npm run dev:desktop
- runs its own Electron-flavored Expo server on the first free port from 8082 through 8089. It never claims port 8081.
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.
The web and desktop dev launchers pass the current Git branch to Metro as
EXPO_PUBLIC_PASEO_DEV_BUILD_LABEL. The expanded desktop sidebar shows it in
the titlebar row. Production builds leave the variable unset and show no label.
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 .#desktopLinux produces the paseo-desktop launcher and desktop entry. macOS producesApplications/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 (see packages/server/src/server/paseo-home.ts).$ROOT/.dev/paseo-home
- Repo dev scripts default to , 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_WORKTREE_PATH/.dev/paseo-home
- Paseo-created worktrees seed from $PASEO_SOURCE_CHECKOUT_PATH/.dev/paseo-home by copying durable JSON metadata. Runtime files like pid files, sockets, and logs are not copied.packages/app/ios
- This repo's worktree setup also best-effort seeds 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:
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 homeDaemon endpoints
- Stable daemon launched by the desktop app: localhost:6767.localhost:6768
- Root checkout dev daemon: .http://localhost:8081
- Root checkout Expo: .8082
- Root checkout desktop dev Expo: first free port from through 8089.npm run dev
- (Windows): localhost:6767 for 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/download for a specific version. It provides xcodebuild and xcrun simctl; accept its license and let first-run component installation finish before starting the service.iPhone 16 Pro
- 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 by default (override with PASEO_IOS_DEVICE_TYPE) and falls back to any iPhone; it fails with No iPhone simulator device type is installed when none exist.expo prebuild
- Homebrew — CocoaPods itself installs automatically: runs pod install on a cold worktree, and when the CocoaPods CLI is missing the runner installs it for you. It tries gem install cocoapods first and falls back to Homebrew (brew install cocoapods), so having Homebrew available lets that fallback succeed without a manual step.
serve-sim, Expo, and Metro come from npm ci, and CocoaPods installs itself on the first prebuild as described above.
The service is designed for concurrent worktrees: it derives a deterministic simulator identity from the worktree path, uses the worktree's assigned PASEO_PORT, pins serve-sim to that simulator UDID, and only tears down that worktree's helper/simulator state. It must not rely on the globally booted simulator or any fixed Metro port.
Worktree setup best-effort seeds the generated iOS project and newest native build cache from the source checkout before the service runs. The service still validates the native project by running Expo prebuild and Xcode; the seed only avoids paying all setup/build cost from a cold worktree every time.
Starting the service must not create, focus, reveal, or leave behind macOS Simulator.app windows — a guard hides Simulator.app every 250ms, so the native window vanishes if you focus it. The user-visible surface is the interactive /.sim preview: a serve-sim stream (60 FPS MJPEG + a WebSocket control channel) that Metro mounts at basePath: "/.sim" (packages/app/metro.config.cjs) and that forwards taps and gestures, so first-launch prompts like "Open in PaseoDebug?" are answered there, not in the native window. Open the ${PASEO_URL}/.sim link the service prints — not serve-sim's raw stream port (:3100), which is view-only. Because the stream sits behind the daemon proxy it is convenient for remote viewing but laggy up close; for fast local dev at the Mac, use the native simulator path below.
Troubleshooting. If xcrun simctl fails with unable to find utility "simctl", the active developer directory is still the Command Line Tools even though Xcode is installed. Point it at Xcode: sudo xcode-select -s /Applications/Xcode.app/Contents/Developer, then confirm with xcrun --find simctl.
Running the iOS app on a local simulator
For fast, native, interactive iOS dev at the Mac — as opposed to the remote /.sim preview above — skip the service and build the dev client directly:
npm run ios # → expo run:ios (packages/app): builds and launches the app in the real Simulator.appexpo 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 servicenpm run dev:server
EXPO_PUBLIC_LOCAL_DAEMON=localhost:6768 npm run ios # standalone
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--remote-debugging-port=0
renderer CPU profiles can be captured through CDP. By default it passes, so Chromium atomically asks the OS for an availablePASEO_ELECTRON_REMOTE_DEBUGGING_PORT
port and prints the selected DevTools endpoint. Set 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.
The desktop workspace script execs the dev runner so the terminal owns the runnerSIGHUP
PID. Terminal shutdown reaches the runner as ; the runner stops Metro and
asks Electron to quit through its normal app lifecycle. Do not add an npm wrapper or
detach Electron: either change 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/desktopThe verifier reads the same EXPO_PORT andPASEO_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, soPASEO_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 wantRenderProfile
to measure with , then open the app with ?renderProfile=1. WhenRenderProfile
the query param is absent, returns children directly and records
nothing.
Captured samples are exposed on globalThis.__PASEO_RENDER_PROFILE__. CallglobalThis.__PASEO_RESET_RENDER_PROFILE__?.() after warm-up and before therecordRenderProfileReasons(id, reasons)
interaction you want to measure. If a memo comparator or subscription boundary
needs explanation, call while profiling;globalThis.__PASEO_RENDER_PROFILE_REASONS__
reason counts are exposed on .
Use this workflow for any render investigation:
1. Add stable RenderProfile boundaries around the suspected root and expensiveactualDuration
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 , 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:
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/appThis 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 samplesDesktop 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.
setupDarwinCompositorWatchdogpackages/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; setPASEO_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 optionalPASEO_LOG_ROTATE_SIZE and PASEO_LOG_ROTATE_COUNT env vars override the10m
defaults. The default rotation is x 3 files everywhere.
Git process pressure
If Git refreshes consume too much CPU, disk, or antivirus capacity, especially on Windows, reduce
the daemon-global Git process limits in $PASEO_HOME/config.json:
{
"daemon": {
"git": {
"maxProcessesPerSecond": 5,
"maxProcessConcurrency": 4
}
}
}Reload the daemon with paseo reload. Environment-variable overrides still require a restart because
the launch environment remains authoritative. Lower values reduce machine pressure but make Git-backed workspace state and
Git RPCs wait longer. See Git process limits for defaults,
semantics, and environment-variable overrides.
Agent Tool Catalog Measurement
Measure the MCP tools/list payload that Paseo injects into agents with:
npm run measure:agent-tools --workspace=@getpaseo/serverThe 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.
Worktree starting refs
A new worktree starts from the current branch's upstream, or the local branch when it has no
upstream. This keeps unpushed local commits out of new workspaces by default. The picker collapses
identical refs; divergent local or non-origin refs remain explicit, qualified choices.
The daemon sends the exact upstream ref because the remote and branch names cannot be inferred.
Worktrees retain that ref for comparisons and updates from base while exposing its branch name to
the UI. Merging into base requires a mutable local target: origin/main maps to local main, while
another remote fails closed until the worktree records an explicit local target. Older daemons omit
the optional field and retain the previous local-first behavior; older worktree metadata without the
exact ref also resolves through its stored branch name.
Worktrees inherit committed Git state only; uncommitted source-checkout changes are not copied.
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: bashPATH
resolved from on macOS/Linux, and PowerShell with -NoProfile onBASH_ENV
Windows. They inherit the daemon environment plus Paseo's lifecycle variables;
login and interactive shell startup files are not loaded, and Bash's cmd.exe /c
hook is unset. ACP single-string terminal commands use the same non-login Bash
behavior on macOS/Linux, but preserve their existing 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, $VARcp
expansion, /rm, or a ./scripts/*.sh entrypoint all fail under PowerShell,bash
and is not guaranteed to exist on Windows. Put that logic in a Node scriptprocess.env
that reads what it needs from and invoke it asnode ./scripts/<name>.mjs. This repo's own setup does exactly that inscripts/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 inpaseo.json. The block accepts an inclusive range such as "3000-4000" or a portScriptportScript
executable. Since 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,PASEO_SCRIPTNAME
workspace ID, branch name, and worktree path. A missing branch is passed as an empty string. The same
values are available as , PASEO_WORKSPACE_ID, PASEO_BRANCH_NAME, andPASEO_WORKTREE_PATH. The script must print one valid TCP port. Paseo trusts the external allocator,portScript
so the port may already be bound. 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-uiOr set the environment variable:
PASEO_WEB_UI_ENABLED=true paseo daemon startOr 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-uiThis 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-audioUse npm run build:server whenever you have changed any daemon/server-facing package and need clean cross-package types or runtime behavior.
The app Metro config disables Watchman and uses Metro's node crawler for exports. Keep that invariant unless you have verified production app exports on machines with and without Watchman installed; distro Watchman builds can differ in capabilities and change Metro's crawl behavior.
For tighter loops, you can rebuild a single workspace:
- Changed packages/protocol/src/ or packages/client/src/: npm run build:client.packages/server/src/
- Changed , packages/cli/src/, packages/relay/src/, or packages/highlight/src/: npm run build:server.npm run build:app-deps
- Changed app build dependencies: .
ACP provider catalog versions
The in-app ACP provider catalog pins package-runner entries (npx, npm exec,uvx
and ) 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 versionsThe 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/rename/archive, paseo heartbeat create/update/delete, and the full paseo schedule group. MCP heartbeat automation is intentionally smaller: create and delete only. Detach remains an explicit user lifecycle action rather than an agent tool. paseo run --new-workspace local|worktree composes workspace creation with agent creation. The old paseo worktree and paseo run --worktree forms are hidden compatibility aliases.
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 projectUse --host <host:port> to point the CLI at a different daemon:
npm run cli -- --host localhost:7777 ls -aDesktop integrations can focus an existing agent without creating one or
sending a message. Use paseo://h/<server-id>/agent/<agent-id>, or runpaseo agent open <agent-id>. The CLI reads the local daemon's server ID by--server <server-id>
default; pass when targeting another server.
Agent state
Agent data lives at:
$PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.jsonFind 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}.jsonlCodex:
~/.codex/sessions/{YYYY}/{MM}/{DD}/rollout-{timestamp}-{session-id}.jsonlTesting 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/npm run deploy:web --workspace=@getpaseo/app
directory to Cloudflare Pages with .
PWA install metadata lives in packages/app/public/manifest.json and is linkedpackages/app/public/index.html
from . Keep the install icons in public/ soexpo export`.
Cloudflare serves them from stable root URLs after
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-doctorDiagnoses version mismatches and native module issues.
Typecheck
Always run typecheck after changes:
npm run typecheck---