* feat(hub): guide starter setup (#3651) * feat(hub): guide setup with provider IDs * fix(hub): preserve login setup state * feat(hub): select starter agent runtime Use the connected daemon's provider snapshot so generated Hub bundles only contain complete provider, model, and mode combinations. * fix(hub): stage starter runtime choices Keep providers with valid modes selectable when the daemon has no default mode, so users explicitly choose the complete runtime. * fix(hub): scope runtime choices to workspace Provider capabilities can vary by repository, so request the snapshot for the bundle directory that guided setup will deploy. * fix(server): stop short start gates from undercutting provider startup budgets (#3621) * fix(server): stop short start gates from undercutting provider startup budgets Agent creation still failed on contended cold starts after #3578, because two shorter gates on the same path fire before the event-stream readiness budget can apply. A traced creation reached session.create twelve seconds in, past the 10s session.create gate, and the agent was then killed by a bare "timeout" at fifteen seconds from AGENT_RUN_START_TIMEOUT_MS while the SSE connect was still running. A run-start bound of 15s wraps a path that is deliberately allowed 30s, so the outer gate always wins and the inner budget is unreachable. Give session.create the shared OPENCODE_SERVER_STARTUP_TIMEOUT_MS budget, since creating the first session for a directory is part of OpenCode coming up, and derive its error message from the constant instead of hardcoding "10s". Raise AGENT_RUN_START_TIMEOUT_MS to 60s with a comment recording that it must stay above the slowest provider startup budget; it stays a literal because that module is provider-agnostic. Related to #3571. * test(server): drive the run start budget through a real AgentManager The run start boundary tests replaced AgentManager.waitForAgentRunStart with a bare timer, so they never exercised the production run-state transition or the agent_state subscription the real wait depends on. That path could regress while the tests stayed green. Build the scenario on a real AgentManager with a provider session that holds its turn start open for a configurable span, so pendingRun.started, the lifecycle change to "running", and the agent_state notification are what the wait actually observes. Budgets stay independent literals and the settled-before/after assertions are unchanged. * fix(server): describe agent run start timeouts --------- Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com> * fix(relay): validate handshake keys (#3669) * fix(files): keep large source previews responsive (#3665) Large read-only files bypassed the editor's virtualization and eagerly mounted every highlighted token, which could stall the renderer for seconds and consume gigabytes of memory. Route source previews through bounded platform renderers and reject reads above their display budgets before accumulation. * fix(hub): make guided setup choices actionable (#3677) Show a concise login success state, scope starter choices to available Hub connections, wait for daemon capabilities, and avoid partial starter bundles when setup cannot proceed. * Keep released agent SDK versions reproducible (#3678) Pin the validated provider and protocol SDK dependency pair so fresh installs cannot silently float after a Paseo release. Refresh the Nix npm dependency hash for the resulting lockfile. * feat(app): refine agent profile creation (#3680) Keep profiles as the richer primitive while restoring the familiar star cue and clearer guidance for agent selection. Account for borders in shared field geometry so controls preserve their declared heights across form consumers. * fix(app): restore native composer auto-growth (#3681) Native multiline inputs were always scrollable, which kept iOS content-size measurement pinned to one line. Keep native sizing intrinsic below the cap and enable internal scrolling only after the composer reaches it. * docs(hub): document zero-friction onboarding (#3657) Lead with the browser-to-CLI onboarding journey, document the generated starter bundle, and align guided setup with available Hub connections and daemon-reported runtime defaults. * Surface workspace changes from agent panes (#3682) * feat(app): refine workspace pane actions Prioritize Changes when available, keep Files useful before a selection, and reserve desktop diff clicks for selection so comments require the explicit affordance. Move the Changes shortcut to Shift+G to avoid consuming a likely copy variant. * feat(app): improve access to workspace changes * Expand workspace panel actions and keyboard navigation (#3685) * fix(app): keep file navigation in place Retargeting a File tab allocated a new tab identity, causing the tab track to unmount and remount during navigation. Preserve the identity for File-to-File changes while retaining new instances for cross-kind replacements. * feat(app): expand workspace panel actions Keep the Command Center aligned with workspace tab and pane primitives. Add explicit panel placement, focused open behavior, keyboard navigation for empty panes, and a Changes pill that toggles the visible Side panel without closing its tab. * Restore compact and stable chat timelines (#3716) Restore compact message and footer spacing, reserve independent composer-pill clearance for transcript content and floating controls, and keep visible web tool-call expansion from shifting the reading position. * perf(app): defer replica persistence until user inactivity Whole-replica capture and serialization blocked the JS thread during navigation and streaming. Defer that work until the user has been inactive while allowing long-running streams to persist without becoming the idle signal. * Give every pane a browser-style New tab (#3715) * feat(app): replace empty panes with New tabs Give every retained pane an independently identified New tab, replace it in place when a destination is chosen, and preserve split close and move invariants. Keep existing workspace startup and compact explorer routing explicit, with focused unit and browser regression coverage. * test(app): count content tabs in subagent integration * fix(app): preserve workspace shortcut ownership * fix(app): keep compact model options scrollable The compact model sheet had no sheet-aware scroll owner when profiles filled its viewport, leaving the controls below them unreachable. Keep the root provider view inside Gorhom's scroll container so native sheet gestures work on both platforms. * fix(app): order new tab launcher actions by workflow * fix(app): show the New agent shortcut The command center resolved New agent through the New tab binding, so it advertised Cmd+T despite creating an agent. Resolve the target-agent binding instead and cover the mapping. * fix(app): prioritize side panel launcher actions Lead with supporting workspace tools when the launcher is in the Side panel, while ordinary panes continue to lead with agent creation. * fix(app): align terminal profiles header * docs: add 0.5.0-beta.5 changelog * test(app): align new tab launcher expectations * chore(release): cut 0.5.0-beta.5 * fix: update lockfile signatures and Nix hash [skip ci] * fix(app): make compact git actions menu-only Keep compact toolbars narrow and require users to choose potentially consequential Git and workspace actions from the menu. * fix(app): keep pinned project actions reachable (#3722) * fix(app): keep pinned project actions reachable Preserve an empty project shell after its final workspace is hoisted into the pinned section so project-scoped workspace creation remains available. Co-authored-by: Agent Op <agentop718@gmail.com> * test(app): verify pinned project workspace context --------- Co-authored-by: Agent Op <agentop718@gmail.com> Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com> * fix(app): stop a string SVG transform from crashing the app (#3711) * fix(app): stop a string SVG transform from crashing the app A root <Svg> is a real React Native view, so react-native-svg rewrites a *string* `transform` into RN style syntax in `extractTransformSvgView`, using a PEG parser whose grammar only accepts SVG transform functions. Upstream lets that parser's SyntaxError escape, so any string it does not understand takes the whole app down during render: SyntaxError: Expected transform functions but "n" found. "found": "n", "location": { "start": { "offset": 0 } } `none` reproduces that message character for character; so does any other CSS value (`rotate(-90deg)`, `translate(10px, 0)`). Every sibling extractor in that file already catches the same error and degrades, so the asymmetry is the bug: a string that is merely ignored on a <Path> crashes on an <Svg>. We feed that path untrusted input. The daemon scans a project for favicon.svg / icon.svg / logo.svg (packages/server/src/utils/project-icon.ts) and ships it to the client, where ProjectIconImage hands it to SvgCss. Running the library's own XML parser over `<svg style="transform:none">` yields exactly `transform: "none"` on the root, which is why the crash starts once a host connects and the project icons render, with no terminal involved. Note this is *not* reachable from an RN-style array transform: the extractor returns arrays untouched and only parses strings, so `style={{ transform: [{ rotate: "-90deg" }] }}` (the context window meter and the checks ring) was never a factor. Patch the three builds a consumer can resolve - Metro takes the package's `react-native` field (src/*.ts), Node and Vitest take main/module - and register react-native-svg with the postinstall patch runner so the patch actually applies. The new test pins all three. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EDwfsMWoSFfPm2cFXe5noQ * test(app): cover parsed root SVG transforms Exercise the dependency patch with root props produced by the library's XML and CSS parsers, including embedded style rules that can arrive through project icons. Keep dependency-patch documentation focused on the reusable install and resolution constraints. --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com> * fix(pairing): warn that pairing links grant daemon access (#3734) A pairing offer remains valid across daemon restarts, so remove the incorrect rotation guidance and warn users where the durable credential is displayed. * fix(app): preserve timeline retry backoff (#3736) Equivalent visibility publications were acting as implicit retry commands, so unrelated tab and layout changes could restart failed timeline work. Keep visibility identity stable and reserve immediate retries for explicit user action. * Open the tab chooser beside the pointer (#3735) * feat(app): open the tab chooser beside the pointer Keep keyboard-created and empty-pane New tabs as persistent launcher surfaces, while giving pointer users an anchored chooser at the pane-local plus button. Both presentations resolve the same launch catalogue so future ordering and customization cannot drift. * test(app): follow the anchored tab chooser The browser helpers still searched inside the pane for persistent-launcher controls after clicking the portalled menu trigger. Exercise the menu through its own roles and stable catalogue IDs. * fix(app): restore composer height lifecycle (#3740) Native Fabric can omit the initial content-size callback and retain stale size after deletion. Keep the common composer surface free of platform branches while native and web adapters own their measurement mechanisms. * fix(server): stop quota fetcher from writing provider credentials (#3442) * fix(server): recover already-settled interrupts (#3742) * fix(server): recover already-settled interrupts Provider state can drift from Paseo’s tracked run. Treat an authoritative already-idle interrupt as settled and retire the captured run token so retrying can start new work without weakening ambiguous-cancellation protection. * fix(codex): fence interruption during turn startup Reserve foreground-start ownership before asynchronous provider setup. An interrupt can now cancel preflight before turn/start or wait for an issued start to become interruptible, preventing acknowledged cancellation from leaving unmanaged work. * docs(changelog): prepare 0.5.0 stable release * chore(release): cut 0.5.0 * fix: update lockfile signatures and Nix hash [skip ci] * fix(app): stabilize native composer resizing JavaScript height state competed with native intrinsic layout across draft and agent ownership transitions. Let native inputs own sizing and scrolling while preserving DOM measurement on web. * docs(changelog): prepare 0.5.1 stable release * chore(release): cut 0.5.1 * fix: update lockfile signatures and Nix hash [skip ci] * perf(app): move replica cache validation to restore Add an observational live measurement harness for the row-store migration baseline. Trust already-shaped cache values on persist while retaining full schema validation at the restore boundary. * feat(app): add replica cache row store Ship the platform storage engines behind a dark interface so the cache migration can wire them separately. Both engines share a contract, including atomic writes and destructive schema-version resets. * feat(app): switch replica cache to row storage Persist only changed replica entities so steady-state writes scale with the update rather than the complete cached world. Restore remains an all-or-nothing validated boundary, with whole-host byte-budget eviction. * fix(app): harden replica row persistence Serialize pending batches at write execution time so retries cannot overwrite newer rows or resurrect removed hosts. Reject oversized hosts before storage writes and preserve unchanged directory identities so routine reconciliation stays row-grained. --------- Co-authored-by: BrianAguilarWasco <146237330+BrianAguilarWasco@users.noreply.github.com> Co-authored-by: paseo-ai[bot] <266920839+paseo-ai[bot]@users.noreply.github.com> Co-authored-by: Yuri Gui <yugui923@users.noreply.github.com> Co-authored-by: Agent Op <agentop718@gmail.com> Co-authored-by: Omer Cohen <639682+omercnet@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Dan Berindei <dan.berindei@gmail.com>
34 KiB
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/workspace-labels/ |
Host-local label catalog, assignment mutations, and explicit subscriptions |
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/orchestration-skills/ |
Bundled catalog, host selection, convergence, and skill-directory transactions |
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.
PaseoApi is the capability-only boundary over workspaces, agents, providers, and config.
PaseoClient adds connection lifecycle. App plugin surfaces borrow an API over their selected
host's client; plugin subprocesses use the same facade over a host-owned IPC transport.
packages/app — Mobile + web client (Expo)
Cross-platform React Native app that connects to one or more daemons.
- Expo Router navigation (
/h/[serverId]/workspace/[workspaceId],/h/[serverId]/agent/[agentId], etc.). TheworkspaceIdURL segment is an opaque workspace id, not a directly meaningful filesystem path. HostRuntimeControllermanages saved host connections, reconnection, and per-host runtime stateruntime/replica-cachekeeps the complete project, workspace, and active-agent directory plus one short focused timeline tail in a row store. It restores before navigation becomes ready and leaves remote hydration flags false.runtime/directory-syncowns directory reconciliation. On reconnect it passes the persisted per-entity cursor throughproject.list,fetch_workspaces, andfetch_agents; the daemon returns each entity's latest projection when its sequence is newer, plus tombstones.workspace-labelsowns one sequenced catalog replica per connected host, the deterministic cross-host projection that surfaces spanning hosts use (the filter page, the manager), and the per-host resolution a workspace row's chips use. Two hosts may give one name different colors, so a row resolves against its own host's catalog and a merged answer would be wrong there. Catalogs never synchronize between hosts; assignment creates a missing definition only on the target host. On the daemon, catalog and assignment rewrites share a journaled commit boundary. Startup recovery completes that commit before workspace or catalog publication.SessionContextwraps 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 intocomponents/,hooks/, orscreens/workspace/ - Timeline reducers in
timeline/session-stream-reducers.tshandle compaction, gap detection, sequence-based deduplication - Timeline sync correctness is documented in docs/timeline-sync.md: live streams are for immediacy,
fetch_agent_timeline_requestis 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. The row store is not encrypted, so the cached
timeline tail may contain source code, prompts, and tool output; encrypted-at-rest storage is a
separate product/security decision. Agents, workspaces, and projects have individual rows; the
focused timeline tail and directory checkpoint each have one row per host. Writes serialize only
rows whose in-memory identities changed. Restore validates every row before hydration; one invalid
row clears the cache and starts cold. The 32 MiB byte budget is tracked incrementally from row
payload lengths and evicts whole hosts in least-recently-written order. After restart, that order is
an approximation rebuilt from the row store's readAll() order. Browser and Electron builds use
IndexedDB. Native builds use expo-sqlite.
The three directory entity types have independent monotonic sequences and share one daemon generation. The daemon retains only the latest projection per entity and bounded tombstones, not an event log. A missing, expired, or previous-generation cursor receives a full snapshot. Projects are independent records; a project with no workspaces does not need a workspace placeholder.
Workspace label definitions use a separate, explicitly subscribed sequence. The list request both fetches and grants live updates for that session. A current cursor receives an empty correlated catch-up response when nothing changed; idle sessions and unsubscribed sessions receive no label traffic. Workspace assignments stay on the workspace directory sequence.
packages/cli — Command-line client
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/modepaseo daemon start/stop/restart/status/pair/set-passwordpaseo terminal ls/create/capture/send-keys/killpaseo script ls/start/stoppaseo schedule create/ls/inspect/update/pause/resume/run-once/logs/deletepaseo heartbeat create/update/deletepaseo project create/ls/rename/deletepaseo workspace create/ls/rename/archivepaseo permit allow/deny/lspaseo provider ls/models- hidden legacy
paseo worktree create/ls/archivecompatibility 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.
DaemonConfigStorepersists 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.useTlsorPASEO_RELAY_USE_TLS=true; the public (client-facing) TLS setting can be overridden independently viadaemon.relay.publicUseTlsorPASEO_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
The desktop does not manage agent skills. It retains one compatibility reader for the old
skill-selection.json, imports that preference into its managed local daemon, then deletes the old
file after the daemon confirms persistence.
Multi-window (hybrid land-on model). createWindow() in main.ts is reusable: ⌘⇧N/File→New Window, relaunching the app (second-instance), and the sidebar "Open in new window" action each open a fresh BrowserWindow. Every window shows the full sidebar — there is no per-window project ownership or filtering. "Land on a project" is delivered by a per-webContents PendingOpenProjectStore: each window pulls its own pending project path on mount (paseo:get-pending-open-project) and runs the normal open-project flow, identical to a CLI paseo <path> launch.
Window-state v1 limitation: only the first window of a session restores and persists saved geometry (size/position/maximized). Windows opened via ⌘⇧N / second-instance / "Open in new window" open at the default size, OS-cascaded, and do not persist — this avoids every window stacking on the same restored bounds and fighting over the single window-state store. Lifting this needs per-window state keys.
In-app browser profile. Every browser guest uses one stable persistent Electron session, so cookies, authentication, cache, and site storage are shared across tabs, workspaces, and desktop windows and survive tab or app closure. Browser identity is independent of that storage partition: after every
did-attach, the renderer explicitly registers its browser id, workspace id, and current 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, preserving
window.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 resolver
Agent browser_keypress -> guest sendInputEvent(skipIfUnhandled)
|-- guest handles ------------> page owns it
`-- guest does not handle ----> stop; never redispatch to the host window
packages/website — Marketing site
TanStack Router + Cloudflare Workers. Serves paseo.sh.
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 fullcheckout_*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) - Request/response pairs for fetch, list, create, etc., correlated by
requestId; failures userpc_error
directory_suggestions_request is one daemon-owned filesystem search capability. The daemon
configures the same searchDirectoryEntries engine with a root, output format, path-query policy,
entry-kind filters, match mode, blank-query behavior, and hidden-directory traversal policy. A
request without cwd searches the host home for absolute project paths; a request with cwd
searches that workspace and returns relative entries. Clients may prepend their small host-scoped
recent-project list for bare queries, but must not parse filesystem query syntax or re-filter a
correlated daemon response. The legacy directories response field remains a projection of the
typed entries list.
Binary frames (terminal stream protocol):
Terminal I/O is sent as binary WebSocket frames decoded by decodeTerminalStreamFrame in shared/binary-frames/terminal.ts. The layout is:
- 1-byte opcode:
Output (0x01),Input (0x02),Resize (0x03),Snapshot (0x04) - 1-byte slot: terminal slot id
- variable payload: bytes for output/input, JSON-encoded
{ rows, cols }for resize, terminal snapshot for snapshot
Terminal PTY size is last-interacting-client-wins. A client claims the PTY size only when its terminal viewport genuinely changes size or the user focuses/taps the terminal. Passive rendering work — attaching, restoring visibility, font settling, renderer refits, or just looking at a visible terminal — must not send a resize frame. The server does not broadcast resize ownership; the resized PTY redraws through normal output, and every attached client renders that output in its own local viewport.
There is also a separate file-transfer binary frame format in the same directory, used for download/upload streams.
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). Sessionstores client capabilities from thehellohandshake 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
timestampvalues are canonical daemon-owned timestamps. Providers may supply original replay timestamps, but clients must not guess timestamp trust or hide time UI based on local clock heuristics. - Events stream to connected clients in real time; correctness is backed by authoritative timeline fetches and paged-to-completion catch-up.
- Agent state persists to
$PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json. Timeline rows are runtime memory; provider history is the durable transcript authority and resumed agents rebuild from it. That storage path is derived fromcwd, not from workspace id.
Right-sidebar boundary: directory-backed vs workspace-owned
Two workspaces can share the same cwd (e.g. a directory workspace and a local_checkout workspace on the same folder, or several workspaces opened against one checkout). Model B keeps these distinct: they share everything the directory determines, but nothing the workspace owns. The right-sidebar surfaces split cleanly along this line, and the split is enforced purely by what each piece of state is keyed by.
Directory-backed (shared by same-cwd workspaces) — keyed by (serverId, cwd), never by workspaceId:
| Surface | Key | Source |
|---|---|---|
| Git status | checkoutStatusQueryKey(serverId, cwd) |
packages/app/src/git/query-keys.ts |
| Git diff | checkoutDiffQueryKey(serverId, cwd, mode, baseRef, ws) |
packages/app/src/git/query-keys.ts |
| Forge change request | checkoutPrStatusQueryKey(serverId, cwd) |
packages/app/src/git/query-keys.ts |
| Change request timeline | prPaneTimelineQueryKey({ serverId, cwd, prNumber }) |
packages/app/src/git/pull-request-panel/query-keys.ts |
| File preview content | ["workspaceFile", serverId, cwd, path] |
packages/app/src/components/file-pane.tsx |
| File explorer listings | fetched via listDirectory(workspaceRoot, path) |
packages/app/src/hooks/use-file-explorer-actions.ts |
Workspace-owned (independent per workspace) — keyed by workspaceId (falling back to cwd only when no workspaceId exists):
| State | Key builder / store | Source |
|---|---|---|
| Review draft comments | buildReviewDraftKey / buildReviewDraftScopeKey |
packages/app/src/review/store.ts |
| Diff mode override | review-draft scope key (in-memory) | packages/app/src/review/state.ts |
| Composer attachments | buildWorkspaceAttachmentScopeKey |
packages/app/src/attachments/workspace-attachments-store.ts |
| File explorer nav/open state | fileExplorer map keyed workspace:{workspaceId} |
packages/app/src/hooks/use-file-explorer-actions.ts |
| File explorer expanded paths | expandedPathsByWorkspace[workspaceStateKey] |
packages/app/src/stores/panel-store/state.ts |
diff-pane.tsx is the canonical wiring site: it passes { serverId, cwd } to the git queries and { serverId, workspaceId, cwd } to the draft/override/attachment scope keys.
Do not "fix" the sharing away. Re-keying a directory-backed query by workspaceId makes same-cwd workspaces diverge (two windows onto the same git tree showing different diffs). Re-keying owned state (drafts, expanded paths) by cwd makes them leak between distinct workspaces on the same folder. The workspaceId-keyed builders carry a // workspaceId is opaque; do not parse this key back into a path. comment — the opaque-id fallback to cwd exists only for old payloads without a workspaceId, not as a content-sharing mechanism.
One deliberate non-violation: AgentFileExplorerState.directories/files cache directory listings inside the workspaceId-keyed explorer map. Same-cwd workspaces therefore keep duplicate caches, but they can never diverge — both fetch the identical directory via listDirectory(workspaceRoot, …). This is duplication, not leakage, and is left as-is.
Agent providers
Each provider implements the AgentClient interface in agent/agent-sdk-types.ts. Provider implementations live in agent/providers/.
The built-in, user-facing providers are Claude Code, Codex, Copilot, OpenCode, Pi, and OMP. Additional adapters exist in the same directory for ACP-compatible agents and internal use:
| Provider | Wraps | Session format |
|---|---|---|
Claude (claude/) |
Anthropic Agent SDK | ~/.claude/projects/{cwd}/{session-id}.jsonl |
| Codex | Codex AppServer (codex-app-server) |
~/.codex/sessions/{date}/rollout-{ts}-{id}.jsonl |
| Copilot | GitHub Copilot via ACP | Provider-managed |
| OpenCode | OpenCode server / CLI | Provider-managed |
| Cursor | ACP wrapper (acp-agent) |
Provider-managed |
| Generic ACP | ACP wrapper | Provider-managed |
| Pi | Local Pi RPC process | Provider-managed |
| Mock load test | In-process fake | In-memory |
All providers:
- Handle their own authentication (Paseo does not manage API keys)
- Support session resume via persistence handles
- Map tool calls to a normalized
ToolCallDetailtype - 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
- Client sends
CreateAgentRequestMessagewith config (prompt, cwd, provider, model, mode) - Session routes to
AgentManager.create() - AgentManager creates a
ManagedAgent, initializes provider session - Provider runs the agent → emits
AgentStreamEventitems - Events append to the agent timeline, broadcast to all subscribed clients
- Tool calls are normalized to
ToolCallDetail(shell, read, edit, write, search, etc.) - 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
├── 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
- Local daemon (default):
paseo daemon starton127.0.0.1:6767 - 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
paseo daemon startis left alone (paseo.pidrecordsdesktopManaged). - Remote + relay: Daemon behind firewall, relay bridges with E2E encryption