* feat(schedules): per-run workspaces, isolation and archive controls Scheduled agents never appeared in the sidebar: each schedule reused one stamped workspace and archived the run's agent on completion. Now every run mints its own workspace — the entity the sidebar shows — with per-schedule controls for isolation (local or worktree) and whether to archive that workspace when the run finishes. The schedule form is rebuilt on a non-React form model (open/commands/close) to end the flicker, edit-into-create contamination, and multi-host hydration bugs that came from effect-choreographed shared state. That lands the reusable foundations it needed: a form kit over a single control-geometry owner, a data-access taxonomy (replica/snapshot/fetch) with real load-state semantics, and lint/boundary guardrails so new screens inherit the paved road. Also fixes desktop combobox popovers truncating options when the trigger is narrower than the content. New schedule protocol fields are optional on both create and update, so old and new clients/daemons stay compatible. * test(app): complete mock themes for control-geometry tokens switch and terminal-profile-edit-modal now style through createControlGeometry, which reads theme.borderWidth[1], theme.opacity[50], and theme.colors.borderAccent. The hand-rolled mock themes in these two suites lacked those tokens, so the eager StyleSheet.create mock threw at import (collection error), not on any assertion. Add the missing tokens. * fix(schedules): address review findings on run cleanup, edit isolation, load error, sheet dismiss, and preference hydration - Server: archive the run's workspace even when agent creation fails, so a misconfigured provider no longer orphans a sidebar workspace/worktree. - Edit form: keep a stored worktree isolation while worktree eligibility is still resolving, instead of silently downgrading a saved schedule to local. - Schedules screen: show the load error/retry UI when every host fails, rather than spinning forever behind the loading branch. - Form sheet: fire the parent onClose on native gesture/backdrop dismiss so the sheet closes instead of re-opening. - Form model: apply late-loading saved preferences to untouched create-mode fields (never to edited schedules or user-modified fields). * fix(schedules): review batch — reconnect resubscribe, worktree prune, Android dismiss, cadence and provider edit safety - Push-router: re-send terminal and checkout-diff subscriptions after a reconnect; the old per-hook effect resubscribed on isConnected and the extracted router did not, so daemon restarts silently stopped terminals_changed pushes (root cause of the terminal-activity e2e failures). - Server: pass the run's source repo root when archiving worktree runs so the worktree is unregistered (git worktree remove/prune), not just deleted; also archive the workspace when agent creation fails even with archive-off (an agentless workspace has nothing to inspect). - Sheet: RN Modal onDismiss is iOS-only — notify dismiss once-per-close on the Android native path too; replace the JSDOM component test with a real Playwright dismiss spec and inline the trivial dismiss decision. - Form model: editing an interval schedule no longer rewrites its cadence to cron unless the cadence UI is touched (90-minute intervals have no cron equivalent); switching projects clears stale provider/model state and gates submit until the new host's snapshot resolves. * chore(lint): drop custom lint wrapper and boundary script, plain oxlint The import bans stay in .oxlintrc.json (oxlint-native no-restricted-imports). The receiver-sensitive checks the custom script enforced are not worth a parallel lint pipeline; if they come back it will be as oxlint rules. * fix(schedules): crash-safe run cleanup, capability-gated run options, resilient mutations - Persist the run's workspace/agent ids on the running-run record so a daemon crash mid-run can be recovered: restart archives the interrupted run's workspace under the same policy as normal cleanup (agentless always archives, otherwise archiveOnFinish is respected). - Hide Archive on finish (and omit archiveOnFinish/isolation from payloads) on hosts without workspaceMultiplicity — an old daemon ignores the fields, so offering the switch there would lie. - Optimistic pause/resume/delete no longer throw when the schedules cache holds a still-connecting entry alongside loaded data. - Mode field is gated on provider mode options, not a selected model, so model-less providers can pick their advertised modes.
18 KiB
CLAUDE.md
Paseo is a mobile app for monitoring and controlling your local AI coding agents from anywhere. Your dev environment, in your pocket. Connects directly to your actual development environment — your code stays on your machine.
Supported agents: Claude Code, Codex, GitHub Copilot, OpenCode, and Pi.
Repository map
This is an npm workspace monorepo:
packages/server— Daemon: agent lifecycle, WebSocket API, MCP serverpackages/app— Mobile + web client (Expo)packages/cli— Docker-style CLI (paseo run/ls/logs/wait)packages/relay— E2E encrypted relay for remote accesspackages/desktop— Electron desktop wrapperpackages/website— Marketing site (paseo.sh)
Docs
docs/ is the source of truth for system-level and process-level knowledge. "The docs", "check the docs", or "check the X docs" always mean this directory — not the web. Look here before fetching anything online; the docs capture gotchas and conventions you cannot derive from the code or external sources.
At the start of non-trivial work, list docs/ and skim anything relevant to the task. When you learn something meta worth preserving — a gotcha, a convention, a workflow, a piece of system context that will outlive the current task — update an existing doc or propose a new one. Code-level facts belong in inline comments next to the code; system, process, and gotcha-level facts belong in docs/.
| Doc | What's in it |
|---|---|
| docs/product.md | What Paseo is, who it's for, where it's going |
| docs/architecture.md | System design, package layering, WebSocket protocol, agent lifecycle, data flow |
| docs/agent-lifecycle.md | Agent states, parent/child relationships, archive semantics, tabs vs archive, subagents track |
| docs/data-model.md | File-based JSON persistence, Zod schemas, atomic writes, no migrations |
| docs/glossary.md | Authoritative terminology — UI label wins, no synonyms |
| docs/coding-standards.md | Type hygiene, error handling, state design, React patterns, file organization |
| docs/design.md | Theme tokens — colors, fonts, spacing, radii, icons |
| docs/forms.md | Form architecture — non-React form model, form kit, load-state gating; the schedule form is the golden example |
| docs/hover.md | Hover — the canonical pattern (plain View + onPointerEnter/Leave, separate inner Pressable) and the three ways agents break it |
| docs/unistyles.md | Unistyles gotchas — useUnistyles() is forbidden, alternatives in order |
| docs/floating-panels.md | Anchored popovers — Portal/Modal escape for Android, lifecycle gates, keyboard-shared-value, status-bar offset, the flash |
| docs/expo-router.md | Expo Router route ownership, startup restore, and native blank-screen gotchas |
| docs/file-icons.md | Material icon theme integration for the file explorer |
| docs/providers.md | Adding a new agent provider end-to-end |
| docs/custom-providers.md | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries |
| docs/service-proxy.md | Service proxy: exposing workspace scripts at public URLs, DNS setup, reverse proxy config |
| docs/development.md | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP |
| docs/rpc-namespacing.md | WebSocket RPC naming convention — dotted namespaces and .request/.response pairs |
| docs/protocol-validation.md | zod-aot generated inbound WebSocket validation, patched compiler regressions, schema-purity rules |
| docs/terminal-performance.md | Terminal latency pipeline, coalescing/backpressure invariants, benchmark + perf spec usage |
| docs/testing.md | TDD workflow, determinism, real dependencies over mocks, test organization |
| docs/mobile-testing.md | Maestro and mobile test workflows |
| docs/ad-hoc-daemon-testing.md | Isolated in-process daemon test harness |
| docs/browser-capture-harness.md | Real-Electron browser screenshot harness and compositor-surface gotcha |
| docs/android.md | App variants, local/cloud builds, EAS workflows |
| docs/docker.md | Running the daemon and bundled web UI in Docker, volumes, agent images, security |
| docs/release.md | Release playbook, draft releases, completion checklist |
| docs/terminal-activity.md | Terminal activity indicators — source-agnostic tracker, agent hook reporting, adding a new hook provider |
| SECURITY.md | Relay threat model, E2E encryption, DNS rebinding, agent auth |
Quick start
npm run dev # Start the dev daemon
npm run dev:app # Start Expo against the dev daemon
npm run dev:desktop # Start Electron desktop dev
npm run cli -- ls -a -g # List all agents
npm run cli -- daemon status # Check daemon status
npm run typecheck # Always run after changes
npm run lint # Always run after changes
npm run format # Auto-format with Biome
npm run format:check # Check formatting without writing
Repo dev commands use checkout-local state by default. In this checkout, PASEO_HOME resolves to .dev/paseo-home, and npm run cli -- ... targets that same dev home automatically. The packaged desktop app and production-style daemon keep using ~/.paseo on port 6767.
See docs/development.md for full setup, build sync requirements, and debugging.
Critical rules
-
NEVER restart the main Paseo daemon on port 6767 without permission — it manages all running agents. If you're an agent, restarting it kills your own process.
-
NEVER assume a timeout means the service needs restarting — timeouts can be transient.
-
NEVER add auth checks to tests — agent providers handle their own auth.
-
Before changing app routes, startup routing, remembered workspace restore, or active workspace selection, read docs/expo-router.md.
-
NEVER run the full test suite locally. The test suites are heavy and will freeze the machine, especially if multiple agents run them in parallel. Rules:
- Run only the specific test file you changed:
npx vitest run <file> --bail=1 - Never run
npm run testfor an entire workspace unless explicitly asked. - If you must run a broad suite, pipe output to a file and read it afterward:
npx vitest run <file> --bail=1 > /tmp/test-output.txt 2>&1then read the file. - Never re-run a test suite that another agent already ran and reported green — trust the result.
- For full suite verification, push to CI and check GitHub Actions instead.
- Run only the specific test file you changed:
-
Always run typecheck and lint after every change.
-
Build workspace packages before diagnosing cross-package type errors. This repo consumes generated declarations across workspaces. If typecheck fails in a package that depends on another workspace, rebuild the owning stack first so
distdeclarations are current:npm run build:client— rebuild protocol and client declarations.npm run build:server— rebuild highlight, relay, protocol, client, server, and CLI when server/CLI types may be stale.- Do not patch inferred callback parameters or add local duplicate types just to silence stale declaration errors.
-
Run
npm run formatbefore committing. This repo uses Biome for formatting. Do not manually fix formatting — let the formatter handle it. -
Always use npm scripts for linting and formatting. Do not run tools directly with
npx eslint,npx oxfmt,npx oxlint, or package-local binaries. For targeted checks, pass file paths through the npm script:npm run lint -- packages/app/src/components/message.tsxnpm run format:files -- CLAUDE.md packages/app/src/components/message.tsx
-
The protocol stays backward-compatible. Features don't have to. Two separate contracts:
- Protocol contract (always): schema changes must not break parsing in either direction. An old client must still parse messages from a new daemon; a new daemon must still parse messages from an old client.
- New fields:
.optional()with a sensible default. - Never flip optional → required, remove fields, or narrow types (
string→enum,nullable→ non-null). - Removed fields stay accepted (we stop sending them, not stop reading them).
- Test with: "does a 6-month-old client still parse this?" and "does a 6-month-old daemon still send something this client accepts?"
- Wire schemas are pure structural declarations. Do not add
.transform(),.catch(), or.preprocess()to WebSocket message schemas; put normalization in an explicit post-validation pass. - Plain
z.union()is forbidden when every branch has a shared literal tag. Usez.discriminatedUnion()unless generated-code regression tests prove that specific shape is miscompiled. .default()is acceptable on primitive leaves only. Never put defaults on item schemas for large arrays or big inbound containers.
- New fields:
- Feature contract (per-feature): a new feature may require a new daemon capability. The client detects whether the capability is present and either runs the feature or shows "Update the host to use this." That's it.
- No fallback paths. Don't write a degraded version of a new feature that runs on old daemons. Don't fan out across legacy RPCs to simulate a missing capability. The user upgrades or doesn't get the feature.
- No defensive branches scattered through the feature. Capability detection happens in one place; downstream code reads a clean shape.
- Capability flags live in
server_info.features.*with a single// COMPAT(featureName): added in v0.1.X, drop the gate when floor >= v0.1.Xcomment marking the cleanup site. - Existing functionality keeps working across versions — that's the protocol contract doing its job. New-feature degradation is not the goal.
- New RPCs use dotted namespaces with direction suffixes. Follow docs/rpc-namespacing.md:
domain.provider.operation.requestpairs withdomain.provider.operation.response. Existing flat RPC names will migrate over time; don't add new ones.
- Protocol contract (always): schema changes must not break parsing in either direction. An old client must still parse messages from a new daemon; a new daemon must still parse messages from an old client.
-
All back-compat shims are tagged and dated for cleanup. Every shim that exists for old-client/old-daemon support carries a
COMPAT(name)comment with the version it was added in and a target removal date (typically 6 months out). One grep —rg "COMPAT\("— should produce the full list of cleanup work. Don't bury back-compat in untagged??-fallbacks or optional-chain tunnels — that's how it stops being deletable.
Platform gating
The app runs on iOS, Android, web (browser), and web (Electron desktop). Code is cross-platform by default. Gate only when you must. Import gates from @/constants/platform.
The four gates
| Gate | Type | When to use |
|---|---|---|
isWeb |
constant | DOM APIs — document, window, <div>, addEventListener, ResizeObserver. This is the exception, not the default. |
isNative |
constant | Native-only APIs — Haptics, StatusBar.currentHeight, push tokens, camera/scanner, expo-av. |
getIsElectron() |
cached fn | Desktop wrapper features — file dialogs, titlebar drag region, daemon management, app updates, dock badges. |
useIsCompactFormFactor() |
hook | Layout decisions — sidebar overlay vs pinned, modal vs full screen, single-panel vs split. From @/constants/layout. |
Decision matrix
| I need to... | Use |
|---|---|
Access DOM (document, window, <div>, addEventListener) |
if (isWeb) |
| Use a native-only API (Haptics, push tokens, camera) | if (isNative) |
| Use an Electron bridge (file dialog, titlebar, updates) | if (getIsElectron()) |
| Switch layout between phone and tablet/desktop | useIsCompactFormFactor() |
| Show something on hover, always-visible on native | isHovered || isNative || isCompact (hover only works on web) |
| Gate to iOS or Android specifically | Platform.OS === "ios" / Platform.OS === "android" (rare, keep inline) |
Rules
- Default is cross-platform. Don't gate unless you have a specific reason.
- Prefer Metro file extensions over
ifstatements. When a module has fundamentally different implementations per platform, use.web.ts/.native.tsfile extensions instead of runtimeif (isWeb)branches. Metro resolves the correct file at build time — the unused platform code is never bundled. Reserveif (isWeb)for small, inline checks (a single line or a few props). If you find yourself writing a largeif (isWeb) { ... } else { ... }block, split into separate files instead.Import ashooks/ use-audio-recorder.web.ts ← uses Web Audio API use-audio-recorder.native.ts ← uses expo-audio@/hooks/use-audio-recorder— Metro picks the right file automatically. - Use
.electron.ts/.electron.tsxfor Electron-only web modules. Electron is still the Metrowebplatform, but desktop dev/build setsPASEO_WEB_PLATFORM=electron, so Metro first looks for.electron.*files and falls back to normal.web.*files. Use this when the implementation depends on Electron-only behavior such aswebviewTag, desktop preload APIs, or the Electron bridge. Keep plain browser web in.web.*, and keep native fallbacks in the base file or.native.*.Import ascomponents/ browser-pane.electron.tsx ← Electron <webview> implementation browser-pane.web.tsx ← plain web fallback browser-pane.tsx ← native fallback@/components/browser-pane— Electron desktop gets the.electron.tsxfile, browser web gets.web.tsx, and native gets the native/base implementation. - NEVER use raw DOM APIs without
isWebguard. DOM APIs crash native. Casting a RN ref toHTMLElementis a red flag — ensure the block is web-only. - NEVER use
onPointerEnter/onPointerLeave. They don't fire on native iOS. - Hover only works on web. React Native's
onHoverIn/onHoverOutonPressabledoes NOT fire on native iOS/iPad — the underlying W3C pointer events are behind disabled experimental flags. For hover-to-show UI (kebab menus, action buttons), useisHovered || isNative || isCompactso the controls are always visible on native and hover-to-show on web. - Don't use Platform.OS as a proxy for layout capabilities. Use breakpoints for layout decisions, not platform checks.
- Import
isWeb/isNativefrom@/constants/platform. Never writeconst isWeb = Platform.OS === "web"locally.
Debugging
Find the complete daemon logs and traces in the $PASEO_HOME/daemon.log