diff --git a/SECURITY.md b/SECURITY.md index d582f9cfd..d932be7a3 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -19,18 +19,19 @@ The relay is designed to be untrusted. All traffic between your phone and daemon ### How it works -1. The daemon generates a persistent ECDH keypair and stores it locally -2. When you scan the QR code or click the pairing link, your phone receives the daemon's public key -3. Your phone sends a handshake message with its own public key. The daemon will not accept any commands until this handshake completes. -4. Both sides perform an ECDH key exchange to derive a shared secret. All subsequent messages are encrypted with XSalsa20-Poly1305 (NaCl box). +1. The daemon generates a persistent Curve25519 keypair on first run and stores it at `$PASEO_HOME/daemon-keypair.json` with mode `0600` +2. The pairing URL (rendered as a QR code or opened directly) carries the daemon's public key in its URL fragment (`https://app.paseo.sh/#offer=...`). Fragments are not sent to the web server, so `app.paseo.sh` never sees the key. +3. When the phone connects via the relay, it generates a fresh ephemeral Curve25519 keypair and sends an `e2ee_hello` message containing its public key. The daemon will not process any application messages until this handshake completes. +4. Both sides perform a Curve25519 ECDH key exchange to derive a shared key. All subsequent messages are encrypted with XSalsa20-Poly1305 (NaCl `box`). The wire format is `[24-byte nonce][ciphertext]`, base64-encoded as a WebSocket text frame. -The relay sees only: IP addresses, timing, message sizes, and session IDs. It cannot read message contents, forge messages, or derive encryption keys from observing the handshake. +The relay sees only: IP addresses, timing, message sizes, session IDs, and the plaintext `e2ee_hello` / `e2ee_ready` handshake frames (which contain only public keys). It cannot read message contents, forge messages, or derive encryption keys from observing the handshake. ### Why the relay can't attack you The daemon requires a valid cryptographic handshake before processing any commands. A compromised relay cannot: -- **Send commands** — Without your phone's private key, it cannot complete the handshake +- **Impersonate the daemon to your phone** — Without the daemon's secret key, it cannot derive the shared key, so any traffic it injects fails authenticated decryption on the phone +- **Send commands as you** — The daemon only accepts traffic that decrypts and authenticates under a shared key derived with its own secret key. The phone's keypair is ephemeral per connection, so there is no persistent phone-side secret to steal; protection comes from the daemon's secret key never leaving the daemon. - **Read your traffic** — All messages are encrypted with XSalsa20-Poly1305 (NaCl box) after the handshake - **Forge messages** — NaCl box provides authenticated encryption; tampered messages are rejected - **Replay old messages across sessions** — Each session derives fresh encryption keys, so ciphertext from one session cannot be replayed into another session. Within a live session, replay protection is not yet implemented; the protocol uses random nonces and does not track nonce reuse or message counters. @@ -41,11 +42,11 @@ The QR code or pairing link is the trust anchor. It contains the daemon's public ## Local daemon trust boundary -By default, the daemon binds to `127.0.0.1`. The local control plane is trusted by network reachability, not by an additional authentication token. +By default, the daemon binds to `127.0.0.1`. With no password configured, the local control plane is trusted by network reachability — anything that can reach the daemon socket can control the daemon. This is the same security model Docker documents for its daemon: the security boundary is access to the socket or listening address. -Anything that can reach the daemon socket can control the daemon. This is the same security model Docker documents for its daemon: the security boundary is access to the socket or listening address. +The daemon also supports an optional shared-secret password (set via `auth.password` in `config.json` or the `PASEO_PASSWORD` env var; stored bcrypt-hashed). When configured, every HTTP request must carry `Authorization: Bearer ` and every WebSocket upgrade must include a `Sec-WebSocket-Protocol: paseo.bearer.` subprotocol. Browser WebSocket cannot set custom headers, which is why the token rides in the subprotocol. Health (`GET /api/health`) and CORS preflight (`OPTIONS`) are exempt. The password is intended for direct-TCP exposure (e.g. `tcp://host:port?ssl=true&password=...`); it is **not** a substitute for the relay's E2E encryption when traversing untrusted networks. -If you expose the daemon beyond loopback, such as by binding to `0.0.0.0`, forwarding it through a tunnel or reverse proxy, or publishing it from a Docker container, you are responsible for restricting and securing that access. +If you expose the daemon beyond loopback, such as by binding to `0.0.0.0`, forwarding it through a tunnel or reverse proxy, or publishing it from a Docker container, you are responsible for restricting and securing that access. Setting a password is strongly recommended in that case. For remote access, use the relay connection. It is the supported path for reaching the daemon off-machine, and it adds end-to-end encryption plus a pairing handshake before commands are accepted. @@ -55,7 +56,7 @@ Host header validation and CORS origin checks are defense-in-depth controls for CORS is not a complete security boundary. It controls which browser origins can make requests, but does not prevent a malicious website from resolving its domain to your local machine (DNS rebinding). -Paseo validates the `Host` header on incoming requests against configured hostnames. Requests with unrecognized hosts are rejected. +Paseo validates the `Host` header on every HTTP request and every WebSocket upgrade against an allowlist (Vite-style semantics). By default, only `localhost`, `*.localhost`, and any literal IP address (IPv4 or IPv6) are accepted. Additional hostnames can be configured via `hostnames` in `config.json` or the `PASEO_HOSTNAMES` env var (comma-separated; entries beginning with `.` match a domain and its subdomains; the value `true` disables the allowlist entirely). Requests with unrecognized hosts are rejected with `403 Host not allowed`. ## Agent authentication diff --git a/docs/ad-hoc-daemon-testing.md b/docs/ad-hoc-daemon-testing.md index 4a2d1bef9..87176d24b 100644 --- a/docs/ad-hoc-daemon-testing.md +++ b/docs/ad-hoc-daemon-testing.md @@ -48,7 +48,7 @@ const port = target!.type === "tcp" ? target!.port : null; const client = new DaemonClient({ url: `ws://127.0.0.1:${port}/ws`, - appVersion: "0.1.54", // see gotcha #1 + appVersion: "0.1.70", // see gotcha #1 }); await client.connect(); await client.fetchAgents({ subscribe: { subscriptionId: "test" } }); @@ -78,7 +78,7 @@ import { DaemonClient } from "./test-utils/daemon-client.js"; const daemon = await createTestPaseoDaemon(); const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, - appVersion: "0.1.54", + appVersion: "0.1.70", }); await client.connect(); await client.fetchAgents({ subscribe: { subscriptionId: "test" } }); @@ -116,7 +116,7 @@ Always pass `appVersion`: ```typescript const client = new DaemonClient({ url: `ws://127.0.0.1:${port}/ws`, - appVersion: "0.1.54", + appVersion: "0.1.70", }); ``` diff --git a/docs/android.md b/docs/android.md index bd2f12ef5..3a6f70f6c 100644 --- a/docs/android.md +++ b/docs/android.md @@ -53,6 +53,8 @@ Stable tag pushes like `v0.1.0` trigger: 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. Both workflows also support `workflow_dispatch`; the GitHub APK one takes an existing `tag` input so you can rebuild without cutting a new tag. + ### Useful commands ```bash diff --git a/docs/architecture.md b/docs/architecture.md index 28083e7a6..7cf7962f0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -51,39 +51,51 @@ The heart of Paseo. A Node.js process that: - Exposes an MCP server for agent-to-agent control - Optionally connects outbound to a relay for remote access +All paths are under `packages/server/src/`. + **Key modules:** -| Module | Responsibility | -| ------------------------- | ----------------------------------------------------------------------------- | -| `bootstrap.ts` | Daemon initialization: HTTP server, WS server, agent manager, storage, relay | -| `websocket-server.ts` | WebSocket connection management, hello/welcome handshake, binary multiplexing | -| `session.ts` | Per-client session state, timeline subscriptions, terminal operations | -| `agent/agent-manager.ts` | Agent lifecycle state machine, timeline tracking, subscriber management | -| `agent/agent-storage.ts` | File-backed JSON persistence at `$PASEO_HOME/agents/` | -| `agent/mcp-server.ts` | MCP server for sub-agent creation, permissions, timeouts | -| `providers/` | Provider adapters: Claude (Agent SDK), Codex (AppServer), OpenCode | -| `relay-transport.ts` | Outbound relay connection with E2E encryption | -| `client/daemon-client.ts` | Client library for connecting to the daemon (used by CLI and app) | +| Module | Responsibility | +| ------------------------------- | ---------------------------------------------------------------------------- | +| `server/bootstrap.ts` | Daemon initialization: HTTP server, WS server, agent manager, storage, relay | +| `server/websocket-server.ts` | WebSocket connection management, hello handshake, binary frame routing | +| `server/session.ts` | Per-client session state, timeline subscriptions, terminal operations | +| `server/agent/agent-manager.ts` | Agent lifecycle state machine, timeline tracking, subscriber management | +| `server/agent/agent-storage.ts` | File-backed JSON persistence at `$PASEO_HOME/agents/` | +| `server/agent/mcp-server.ts` | MCP server for sub-agent creation, permissions, timeouts | +| `server/agent/providers/` | Provider adapters (see "Agent providers" below) | +| `server/relay-transport.ts` | Outbound relay connection with E2E encryption | +| `server/schedule/` | Cron-based scheduled agents | +| `server/loop-service.ts` | Looping agent runs that retry until an exit condition | +| `server/chat/` | Chat rooms for agent-to-agent and human-to-agent messaging | +| `client/daemon-client.ts` | Client library for connecting to the daemon (used by CLI and app) | +| `shared/messages.ts` | Zod schemas for the entire wire protocol | +| `shared/binary-frames/` | Terminal stream and file transfer binary frame codecs | ### `packages/app` — Mobile + web client (Expo) Cross-platform React Native app that connects to one or more daemons. -- Expo Router navigation (`/h/[serverId]/agents`, etc.) -- `DaemonRegistryContext` manages saved daemon connections +- Expo Router navigation (`/h/[serverId]/workspace/[workspaceId]`, `/h/[serverId]/agent/[agentId]`, etc.) +- `HostRuntimeController` manages saved host connections, reconnection, and per-host runtime state - `SessionContext` wraps the daemon client for the active session -- `Stream` model handles timeline with compaction, gap detection, sequence-based deduplication +- Timeline reducers in `timeline/session-stream-reducers.ts` handle compaction, gap detection, sequence-based deduplication - Voice features: dictation (STT) and voice agent (realtime) ### `packages/cli` — Command-line client -Commander.js CLI with Docker-style commands: +Commander.js CLI with Docker-style commands. Common agent operations are also exposed at the top level (e.g. `paseo ls`, `paseo run`). -- `paseo agent ls/run/stop/logs/inspect/wait/send/attach` -- `paseo daemon start/stop/restart/status/pair` +- `paseo agent ls/run/import/attach/logs/stop/delete/send/inspect/wait/archive/reload/update/mode` +- `paseo daemon start/stop/restart/status/pair/set-password` +- `paseo chat ls/create/inspect/post/read/wait/delete` +- `paseo terminal ls/create/capture/send-keys/kill` +- `paseo loop run/ls/inspect/logs/stop` +- `paseo schedule create/ls/inspect/update/pause/resume/run-once/logs/delete` - `paseo permit allow/deny/ls` - `paseo provider ls/models` -- `paseo worktree ls/archive` +- `paseo worktree create/ls/archive` +- `paseo speech …` Communicates with the daemon via the same WebSocket protocol as the app. @@ -113,30 +125,48 @@ TanStack Router + Cloudflare Workers. Serves paseo.sh. ## WebSocket protocol -All clients speak the same binary-multiplexed WebSocket protocol. +All clients speak the same WebSocket protocol over a single connection that mixes JSON text frames and a small binary framing for terminal streams. Schemas live in `packages/server/src/shared/messages.ts`. **Handshake:** ``` -Client → Server: WSHelloMessage { id, clientId, version, timestamp } -Server → Client: WSWelcomeMessage { clientId, daemonVersion, sessionId, capabilities } +Client → Server: WSHelloMessage { + type: "hello", + clientId, + clientType: "mobile" | "browser" | "cli" | "mcp", + protocolVersion, + appVersion?, + capabilities?: { voice?, pushNotifications?, ... }, + } +Server → Client: status message with payload { status: "server_info", + serverId, hostname, version, capabilities?, features } ``` -**Message types:** +There is no dedicated welcome message; the server emits a `status` session message after accepting the hello, then begins streaming. The session stores client capabilities from the hello and rehydrates them on reconnect, so the wire boundary can ask one question: `session.supports(...)`. + +**Top-level WS envelopes** are `hello`, `recording_state`, `ping`/`pong`, and `session` (which wraps the rich union of session messages). + +**Notable session message types:** - `agent_update` — Agent state changed (status, title, labels) - `agent_stream` — New timeline event from a running agent -- `workspace_update` — Workspace state changed -- `agent_permission_request` — Agent needs user approval for a tool call -- Command-response pairs for fetch, list, create, etc. +- `workspace_update`, `script_status_update`, `workspace_setup_progress` — Workspace state +- `agent_permission_request` / `agent_permission_resolved` — Tool-call permission flow +- `agent_deleted`, `agent_archived`, `agent_status`, `agent_list` +- `checkout_status_update`, `checkout_diff_update`, and the full `checkout_*` request/response set for git operations +- Terminal subscribe/input/capture commands +- Voice/dictation streaming events (`dictation_stream_*`, `assistant_chunk`, `audio_output`, `transcription_result`) +- Request/response pairs for fetch, list, create, etc., correlated by `requestId`; failures use `rpc_error` -**Binary multiplexing:** +**Binary frames (terminal stream protocol):** -Terminal I/O and agent streaming share the same connection via `BinaryMuxFrame`: +Terminal I/O is sent as binary WebSocket frames decoded by `decodeTerminalStreamFrame` in `shared/binary-frames/terminal.ts`. The layout is: -- Channel 0: control messages -- Channel 1: terminal data -- 1-byte channel ID + 1-byte flags + variable payload +- 1-byte opcode: `Output (0x01)`, `Input (0x02)`, `Resize (0x03)`, `Snapshot (0x04)` +- 1-byte slot: terminal slot id +- variable payload: bytes for output/input, JSON-encoded `{ rows, cols }` for resize, terminal snapshot for snapshot + +There is also a separate file-transfer binary frame format in the same directory, used for download/upload streams. ### Compatibility rules @@ -155,26 +185,44 @@ Example: adding a new enum value ## Agent lifecycle +The lifecycle states are defined in `shared/agent-lifecycle.ts`: + ``` -initializing → idle → running → idle (or error → closed) - ↑ │ - └────────┘ (agent completes a turn, awaits next prompt) +initializing → idle ⇄ running + ↓ ↓ ↓ + error + ↓ + closed ``` -- **AgentManager** tracks up to 200 timeline items per agent -- Timeline is append-only with epochs (each run starts a new epoch) +- `initializing` — provider session is being created +- `idle` — has a live session, awaiting the next prompt +- `running` — provider is currently producing a turn +- `error` — last attempt failed; session is still attached +- `closed` — terminal state, no live session + +`ManagedAgent` is a discriminated union over those lifecycle tags. Notes: + +- **AgentManager** is the source of truth for agent state and broadcasts updates to all subscribers +- Timeline is append-only with epochs (each run starts a new epoch). Storage uses sequence numbers for client-side dedup; the default fetch page is 200 items - Events stream to all subscribed clients in real time -- Agent state persists to `$PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json` +- Agent state persists to `$PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json` (timeline rows live alongside the record) ## Agent providers -Each provider implements a common `AgentClient` interface: +Each provider implements the `AgentClient` interface in `agent/agent-sdk-types.ts`. Provider implementations live in `agent/providers/`. -| Provider | Wraps | Session format | -| -------- | ------------------- | -------------------------------------------------- | -| Claude | Anthropic Agent SDK | `~/.claude/projects/{cwd}/{session-id}.jsonl` | -| Codex | CodexAppServer | `~/.codex/sessions/{date}/rollout-{ts}-{id}.jsonl` | -| OpenCode | OpenCode CLI | Provider-managed | +The three first-class, user-facing providers are Claude Code, Codex, and OpenCode. 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` | +| OpenCode | OpenCode server / CLI | Provider-managed | +| Cursor / Copilot | ACP wrapper (`acp-agent`) | Provider-managed | +| Generic ACP | ACP wrapper | Provider-managed | +| Pi-direct | Direct Anthropic API call | Stateless | +| Mock load test | In-process fake | In-memory | All providers: @@ -195,12 +243,21 @@ All providers: ## Storage +`$PASEO_HOME` defaults to `~/.paseo`. The most important files: + ``` $PASEO_HOME/ -├── agents/{cwd-with-dashes}/{agent-id}.json # Agent state + config +├── agents/{cwd-with-dashes}/{agent-id}.json # Agent record + persisted timeline rows ├── projects/projects.json # Project registry ├── projects/workspaces.json # Workspace registry -└── daemon.log # Daemon trace logs +├── chat/ # Chat rooms +├── schedules/ # Scheduled-agent definitions and runs +├── loops/ # Loop runs and logs +├── config.json # Daemon config (mutable) +├── daemon-keypair.json # Daemon identity for relay/E2EE +├── push-tokens.json # Mobile push tokens +├── paseo.sock / paseo.pid # Local IPC socket and pidfile +└── daemon.log # Daemon trace logs (rotated) ``` ## Deployment models diff --git a/docs/coding-standards.md b/docs/coding-standards.md index cda4af781..c03dda1de 100644 --- a/docs/coding-standards.md +++ b/docs/coding-standards.md @@ -1,188 +1,98 @@ # Coding Standards -These standards apply to all code changes: features, bug fixes, refactors, and performance work. +The core instinct: AI-generated code hedges — it covers every case, layers over instead of cutting in, scatters uncertainty everywhere, wraps in case. A senior engineer commits — to a shape, a boundary, a name, a happy path, a type — and lets everything else fall into place. Every rule below catches a different form of indecision. + +For testing rules, see [testing.md](testing.md). ## Core principles -- **Zero complexity budget** — justify every abstraction with specific benefits -- **Fully typed TypeScript** — no `any`, no untyped boundaries -- **YAGNI** — build features and abstractions only when needed -- **Functional and declarative** over object-oriented -- **`interface`** over `type` when possible -- **`function` declarations** over arrow function assignments -- **Single-purpose functions** — one function, one job -- **Design for edge cases through types** rather than explicit handling -- **Don't catch errors** unless there's a strong reason to -- **No index.ts barrel files** that only re-export — they create unnecessary indirection -- **No "while I'm at it" improvements** — stay focused on the task +- **Zero complexity budget** — every abstraction must justify itself with a specific, current benefit. +- **YAGNI** — build features and abstractions only when needed. A function called once is indirection, not abstraction. +- **No "while I'm at it" cleanups** — make the change you came for. Drive-by edits hide in the diff. +- **Functional and declarative** over object-oriented. +- **`function` declarations** over arrow function assignments. +- **`interface`** over `type` when both work. +- **No `index.ts` barrel files** that only re-export — they create indirection and circular-dep risk. Import from the source. -## Type hygiene +## Comments and noise -### Infer from schemas +- Delete any comment where removing it loses zero information. Comments explain _why_, not _what_. +- No tutorial comments explaining language features (`// Use destructuring to...`). +- No decorative section dividers (`// ===== Helpers =====`). Use files and modules to organize, not ASCII art. +- No hedging comments (`// might need to revisit`, `// should work for most cases`). If you're unsure, investigate. +- No commented-out code. Git remembers. +- No `console.log` / `debugger` left behind. No `TODO: implement` stubs — if it needs to exist, write it. -Never hand-write a TypeScript type that can be inferred from a Zod schema. +## Confidence: commit to a shape -```typescript -// Bad: duplicate type that can drift -const schema = z.object({ procedure: z.string(), args: z.record(z.unknown()) }); -type RPCArgs = { procedure: string; args: Record }; +- Validate at boundaries (network, IPC, user input, file I/O), trust types internally. After the parse, the value is what its type says. +- Every `?.` and `??` past the validation boundary is unconfident code — either the boundary should resolve it, or the type should reflect reality. +- No defensive checks for conditions the type system already rules out (`if (!agent) return` on a non-nullable parameter). +- No `try/catch` "just in case." If you can't say what you're catching and why, don't catch. +- Optionality is a design decision, not a migration shortcut. Distinct valid states → discriminated union. Intentionally empty → explicit `null`. Keep optionality at real boundaries. -// Good: infer from schema -type RPCArgs = z.infer; -``` +## Types -### Named types over inline +- No `any`. No `as` casts to bypass errors. No `@ts-ignore` / `@ts-expect-error`. Narrow with `if` / schema validation; let the compiler check harder, not less. +- If a Zod schema exists, the TypeScript type is `z.infer`. Never hand-write a parallel type. +- One canonical type per concept. Layer-specific views are `Pick` / `Omit`, not duplicated fields. +- Name multi-property object shapes — no inline `Array<{ ... }>` or `Promise<{ ... }>` in signatures, returns, or generic args. +- Use string literal unions, not raw `string`, when the value is one of a known set. Catches typos at compile time. +- Object parameters past the obvious-name threshold: 3+ args, any boolean arg, any optional arg → object. `(thing, true, false, true)` is unreadable at the call site. +- Make impossible states impossible — discriminated unions over `{ isLoading; error?; data? }` bags. -No complex inline types in public function signatures. +## Errors -```typescript -// Bad -function enqueueJob(input: { userId: string; priority: "low" | "normal" | "high" }) {} +- Throw typed error classes that carry the fields a caller would want to read. Plain `Error("Provider X not found")` collapses structured info into a string. +- Catch blocks branch on `instanceof` for what they can handle; rethrow the rest. No `catch (e) { return null }`. +- Separate user-facing copy from log/debug strings — don't make one string serve telemetry, logs, and the UI. +- Fail explicitly. If the caller asked for X and X isn't available, throw — don't silently substitute Y. -// Good -interface EnqueueJobInput { - userId: string; - priority: "low" | "normal" | "high"; -} -function enqueueJob(input: EnqueueJobInput) {} -``` +## Density -### Object parameters +- Nested ternaries are forbidden. A single ternary is fine only when both branches are a single identifier or trivial access (`x ? a : b`). +- Boolean expressions with 2+ clauses or mixed concerns → name the conditions. +- Object literals assemble pre-computed values; don't pack branching and lookups into property positions. +- Operations wrapping operations (`Object.fromEntries(arr.filter(...).map(...))`, `Math.max(...xs.map(...))`) → break into named intermediates. +- Max 3 levels of nesting (callbacks, JSX, control flow). Above that, extract. -If a function needs more than one argument, use a single object parameter. +## Structure and modules -```typescript -// Bad: positional args -function createToolCall(provider: string, toolName: string, payload: unknown) {} +- A directory is a module, not a namespace. One intentional public surface; internal files stay internal. +- Path is part of the name — prefer `provider/registry.ts` over `provider/provider-registry.ts`. If the filename has to do double duty, deepen the path. +- Filenames ending in `-utils`, `-helpers`, `-manager`, `-handler`, `-controller`, `-formatter`, `-builder` are a smell — the path didn't carry enough domain. +- Boundary returns answer the caller's question (`getActiveAgents()`), not "here's my storage" (`getAgents().filter(...)` repeated everywhere). +- One adapter means a hypothetical seam; two adapters means a real one. Don't define a port until something actually varies across it. +- Pass-through modules fail the deletion test — if removing the module makes callers go straight to what they wanted, delete it. +- Centralize policy. The same discriminator (`plan`, `provider`, `kind`, `status`) branched in 3+ files → policy table, not another `else if` per case. +- New features get a home before implementation. A feature smeared across 5 shared files is the same slop as a flat-peer namespace. +- Don't drop new files at the nearest root just because placement is unclear — say so and ask. -// Good: object param -interface CreateToolCallInput { - provider: string; - toolName: string; - payload: unknown; -} -function createToolCall(input: CreateToolCallInput) {} -``` +## Refactoring is a bolt-on test -### One canonical type per concept +- A change should look like a thoughtful edit to existing code, not a new layer next to it. New coordinator wrapping a coordinator, new flag bypassing the normal path, new helper duplicating an existing selector — stop and reshape instead. +- Refactors preserve behavior by default. No removing features to simplify code without explicit approval. +- Have a verification plan _before_ refactoring — name the invariants, confirm a test holds them, write one if not. See [testing.md](testing.md). +- Migrate all callers and remove old paths in the same refactor. No fallback behavior unless explicitly designed. -Don't redefine the same concept in different layer-specific shapes (`RpcX`, `DbX`, `UiX`). Keep one canonical type and add explicit layer wrappers that reference it. +## React -```typescript -// Bad: duplicated fields across layers -type RpcToolCall = { toolName: string; args: Record; requestId: string }; -type DbToolCall = { toolName: string; args: Record; id: string; createdAt: Date }; +- `useEffect` is for synchronizing with external systems (DOM, network, timers, subscriptions). Not for transforming React state. Derived state → compute in render or `useMemo`. +- No effect cascades — chains of effects setting state that triggers more effects almost always want React Query or a reducer. +- `useRef` is for DOM refs and non-rendering identities (timer IDs, AbortController, latest-callback caches). If the value affects what renders next, it's state — model it explicitly with `useReducer` and a discriminated union. +- Server state goes through React Query. Manual `useState` + `useEffect` + `isLoading` + `error` for fetched data is always worse. +- Components render and dispatch — they don't compute transitions. Two-plus interacting `useState`s → extract a reducer. +- Never define components inside other components. Module-scope only. +- Subscribe narrowly: select primitives from stores, pass `status` not `agent`, use `useShallow` / deep-equal when returning derived arrays/objects. +- Stable references for props that cross `memo` boundaries or feed dependency arrays. Static literals at module scope `as const`; derived with `useMemo`; handlers with `useCallback` only when there's a memoized beneficiary. +- Use stable ids for `key`, never array index for reorderable/filterable lists. +- Context for stable values (theme, auth). Store with selectors for state that changes. -// Good: canonical type + wrappers -type ToolCall = { toolName: string; args: Record }; -type ToolCallRequest = { requestId: string; toolCall: ToolCall }; -type ToolCallRecord = { id: string; createdAt: Date; toolCall: ToolCall }; -``` +## Naming -## Make impossible states impossible - -Use discriminated unions instead of bags of booleans and optionals. - -```typescript -// Bad -interface FetchState { - isLoading: boolean; - error?: Error; - data?: Data; -} - -// Good -type FetchState = - | { status: "idle" } - | { status: "loading" } - | { status: "error"; error: Error } - | { status: "success"; data: Data }; -``` - -## Optionality is a design decision - -Don't mark fields optional to avoid migrations. Decide deliberately: - -1. Is optionality actually needed? -2. If there are distinct valid states → discriminated union -3. If value can be intentionally empty → explicit `null` -4. Keep optionality at real boundaries (external input), then resolve it - -## Validate at boundaries, trust internally - -Parse external data once at the boundary with schema validation. Then use typed values everywhere else. - -```typescript -// Bad: optional chaining because shape is unclear -const value = response?.data?.items?.[0]?.name; - -// Good: validate at boundary, trust the types -const parsed = responseSchema.parse(rawResponse); -const value = parsed.data.items[0].name; -``` - -## Error handling - -- **Fail explicitly** — if caller requests X and X is unavailable, throw rather than silently returning Y -- **Use typed domain errors** — not plain `Error`. Carry structured metadata for handling, logging, and user messaging -- **Preserve error semantics** — don't collapse meaningful typed errors into generic `Error` - -```typescript -class TimeoutError extends Error { - constructor( - public readonly operation: string, - public readonly waitedMs: number, - ) { - super(`${operation} timed out after ${waitedMs}ms`); - this.name = "TimeoutError"; - } -} -``` - -## Keep logic density low - -Avoid packing branching, lookup, and transformation into single dense expressions. - -```typescript -// Bad: nested ternaries + inline lookups -const billing = shouldUseLegacy(account) - ? getLegacy(account) - : buildBilling( - account, - rates.find((r) => r.region === account.region), - ); - -// Good: named steps, then assemble -const rate = rates.find((r) => r.region === account.region); -if (!rate) throw new MissingRateError(account.region); -const billing = shouldUseLegacy(account) ? getLegacy(account) : buildBilling(account, rate); -``` - -## Centralize policy - -When the same discriminator (`plan`, `provider`, `kind`, `status`) is checked across multiple files, centralize it into a policy model. A new case should require editing one place, not many. - -## React: keep components dumb - -- Components render state and dispatch events — they don't compute transitions -- If a component has more than two interacting `useState` calls, extract a state machine or reducer -- `useRef` for mutable coordination state (flags, timers) is a smell — model states explicitly -- Never mirror a source of truth into local state; derive from it -- Test state logic as pure functions without rendering - -## File organization - -- Organize by domain first (`providers/claude/`), not by technical type (`tool-parsers/`) -- Name files after the main export (`create-toolcall.ts`) -- Use `index.ts` as an entrypoint, not a dumping ground -- Collocate tests with implementation (`thing.ts` + `thing.test.ts`) - -## Refactoring contract - -Refactoring is structure work, not feature work. - -- Preserve behavior by default, especially user-facing behavior -- Do not remove features to simplify code without explicit approval -- Have a verification strategy before you start -- Fully migrate callers and remove old paths in the same refactor -- No fallback behavior by default — prefer explicit error over silent degradation +- Names describe meaning, not mechanics. `submitForm` over `handleOnClickButtonSubmit`. `running` over `filteredArrayOfRunningAgents`. +- The right length is the shortest unambiguous in context. Inside `AgentManager`, methods are `start`, `stop`, `list`. +- Match the surrounding code's vocabulary. If the codebase uses `getX`, don't introduce `fetchX` / `retrieveX` for the same shape. +- Don't leak implementation into names — `getAgent`, not `queryPostgresForAgent`. If swapping the impl would force a rename, the name is wrong. +- Booleans read as yes/no questions: `isX`, `hasX`, `canX`. Avoid negative booleans (`isNotConnected`). +- `data`, `result`, `info`, `manager`, `temp` are smells — say what the thing _is_. diff --git a/docs/custom-providers.md b/docs/custom-providers.md index dc24bba61..a5a9903d4 100644 --- a/docs/custom-providers.md +++ b/docs/custom-providers.md @@ -83,6 +83,7 @@ Required fields for custom providers: "ANTHROPIC_BASE_URL": "https://api.z.ai/api/anthropic", "API_TIMEOUT_MS": "3000000" }, + "disallowedTools": ["WebSearch"], "models": [ { "id": "glm-4.5-air", "label": "GLM 4.5 Air" }, { "id": "glm-5-turbo", "label": "GLM 5 Turbo", "isDefault": true }, @@ -136,6 +137,7 @@ Required fields for custom providers: "ANTHROPIC_AUTH_TOKEN": "sk-sp-", "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" }, @@ -344,7 +346,7 @@ Required fields for ACP providers: [Gemini CLI](https://github.com/google-gemini/gemini-cli) supports ACP via the `--acp` flag. -1. Install: `npm install -g @anthropic-ai/gemini-cli` or see [Gemini CLI docs](https://github.com/google-gemini/gemini-cli) +1. Install: `npm install -g @google/gemini-cli` or see [Gemini CLI docs](https://github.com/google-gemini/gemini-cli) 2. Authenticate with Google (Gemini CLI handles its own auth) 3. Add to config.json: @@ -550,6 +552,7 @@ A config.json with multiple custom providers: "ANTHROPIC_BASE_URL": "https://api.z.ai/api/anthropic", "API_TIMEOUT_MS": "3000000" }, + "disallowedTools": ["WebSearch"], "models": [ { "id": "glm-4.5-air", "label": "GLM 4.5 Air" }, { "id": "glm-5-turbo", "label": "GLM 5 Turbo", "isDefault": true }, @@ -564,6 +567,7 @@ A config.json with multiple custom providers: "ANTHROPIC_AUTH_TOKEN": "sk-sp-", "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" } diff --git a/docs/data-model.md b/docs/data-model.md index e925abe8d..bd129c30e 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -1,6 +1,6 @@ # Data Model -Paseo uses **file-based JSON persistence** instead of a traditional database. All data is validated at runtime with Zod schemas and written atomically (write to temp file, then rename). There are no migrations — schemas use optional fields with defaults for forward compatibility. +Paseo uses **file-based JSON persistence** instead of a traditional database. All data is validated at runtime with Zod schemas. Most stores write atomically (write to temp file, then rename); a few still use plain `writeFile` — see each section. There is no schema-versioning/migration framework — schemas rely on optional fields with defaults for forward compatibility, with a small amount of inline normalization in `persisted-config.ts` for legacy provider/speech entries. All server-side stores live under `$PASEO_HOME` (defaults to `~/.paseo`). @@ -11,8 +11,12 @@ All server-side stores live under `$PASEO_HOME` (defaults to `~/.paseo`). ``` $PASEO_HOME/ ├── config.json # Daemon configuration +├── server-id # Stable daemon identifier (plain text, "srv_") +├── daemon-keypair.json # E2EE keypair for relay (mode 0600) +├── paseo.pid # Daemon PID lock file +├── daemon.log # Default log file (path configurable) ├── agents/ -│ └── {project-dir}/ +│ └── {sanitized-cwd}/ │ └── {agentId}.json # One file per agent ├── schedules/ │ └── {scheduleId}.json # One file per schedule @@ -26,6 +30,8 @@ $PASEO_HOME/ └── push-tokens.json # Expo push notification tokens ``` +The `agents/{sanitized-cwd}/` directory name is derived from the agent's `cwd` by stripping the filesystem root and replacing path separators with `-` (Windows drive letters become a `C-` style prefix). Atomic writes (temp file + rename): agent records, chat, project/workspace registries, push tokens. Non-atomic (plain `writeFile`): `config.json`, `schedules/*.json`, `loops/loops.json`, `server-id`, `daemon-keypair.json`. + --- ## 1. Agent Record @@ -51,6 +57,7 @@ Each agent is stored as a separate JSON file, grouped by project directory. | `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 | @@ -114,7 +121,7 @@ Each agent is stored as a separate JSON file, grouped by project directory. | `description` | `string?` | | `tooltip` | `string?` | | `icon` | `string?` | -| `value` | `string?` | +| `value` | `string \| null` | | `options` | `AgentSelectOption[]` | --- @@ -130,10 +137,11 @@ Single file, validated with `PersistedConfigSchema`. version: 1, daemon: { listen: "127.0.0.1:6767", - hostnames: true | string[], - mcp: { enabled: boolean }, + hostnames: true | string[], // legacy alias `allowedHosts` is migrated on load + mcp: { enabled: boolean, injectIntoAgents: boolean }, cors: { allowedOrigins: string[] }, - relay: { enabled: boolean, endpoint: string, publicEndpoint: string, useTls: boolean } + relay: { enabled: boolean, endpoint: string, publicEndpoint: string, useTls: boolean }, + auth: { password: string } // bcrypt hash, optional }, app: { baseUrl: string @@ -143,12 +151,10 @@ Single file, validated with `PersistedConfigSchema`. local: { modelsDir: string } }, agents: { - providers: { - [provider: string]: { - command: { mode: "default" } | { mode: "append", args: string[] } | { mode: "replace", argv: string[] }, - env: Record - } - } + // ProviderOverrideSchema; legacy entries with `command: { mode, ... }` are migrated to the + // current shape on load via `migrateProviderSettings`. Custom provider IDs must declare + // `extends` (one of the built-ins or `"acp"`) and `label`. See `provider-launch-config.ts`. + providers: Record }, features: { dictation: { enabled, stt: { provider, model, confidenceThreshold } }, @@ -170,7 +176,7 @@ All fields are optional with sensible defaults. **Path:** `$PASEO_HOME/schedules/{id}.json` -One file per schedule. ID is 8 hex characters. +One file per schedule. ID is 8 hex characters. Writes are direct (not atomic). | Field | Type | Description | | ----------- | ------------------------------------- | -------------------------------- | @@ -255,7 +261,7 @@ Single file containing all rooms and messages. **Path:** `$PASEO_HOME/loops/loops.json` -Single file containing an array of all loop records. +Single file containing an array of all loop records. Writes are direct (not atomic) and serialized through an in-memory queue. On daemon startup any record with `status: "running"` is recovered as `"stopped"` with an interruption log entry. | Field | Type | Description | | ----------------------- | --------------------------------------------------- | ------------------------------------------ | @@ -265,10 +271,12 @@ Single file containing an array of all loop records. | `cwd` | `string` | Working directory | | `provider` | `string` | Default provider | | `model` | `string?` | Default model | +| `modeId` | `string?` | Default mode ID | | `workerProvider` | `string?` | Override provider for workers | | `workerModel` | `string?` | Override model for workers | | `verifierProvider` | `string?` | Override provider for verifiers | | `verifierModel` | `string?` | Override model for verifiers | +| `verifierModeId` | `string?` | Override mode ID for verifiers | | `verifyPrompt` | `string?` | LLM verification prompt | | `verifyChecks` | `string[]` | Shell commands to run as checks | | `archive` | `boolean` | Whether to archive worker agents after use | @@ -344,15 +352,15 @@ Single file containing an array of all loop records. Array of project records. -| Field | Type | Description | -| ------------- | -------------------- | ------------------------------ | -| `projectId` | `string` | Primary key | -| `rootPath` | `string` | Filesystem root of the project | -| `kind` | `"git" \| "non_git"` | | -| `displayName` | `string` | | -| `createdAt` | `string` (ISO 8601) | | -| `updatedAt` | `string` (ISO 8601) | | -| `archivedAt` | `string?` (ISO 8601) | Soft-delete timestamp | +| Field | Type | Description | +| ------------- | --------------------------- | ---------------------------------------- | +| `projectId` | `string` | Primary key | +| `rootPath` | `string` | Filesystem root of the project | +| `kind` | `"git" \| "non_git"` | | +| `displayName` | `string` | | +| `createdAt` | `string` (ISO 8601) | | +| `updatedAt` | `string` (ISO 8601) | | +| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete timestamp; required nullable | --- @@ -362,16 +370,16 @@ Array of project records. Array of workspace records. A workspace is a specific working directory within a project. -| Field | Type | Description | -| ------------- | ----------------------------------------------- | ----------------------- | -| `workspaceId` | `string` | Primary key | -| `projectId` | `string` | FK to Project.projectId | -| `cwd` | `string` | Filesystem path | -| `kind` | `"local_checkout" \| "worktree" \| "directory"` | | -| `displayName` | `string` | | -| `createdAt` | `string` (ISO 8601) | | -| `updatedAt` | `string` (ISO 8601) | | -| `archivedAt` | `string?` (ISO 8601) | Soft-delete timestamp | +| Field | Type | Description | +| ------------- | ----------------------------------------------- | ------------------------------ | +| `workspaceId` | `string` | Primary key | +| `projectId` | `string` | FK to Project.projectId | +| `cwd` | `string` | Filesystem path | +| `kind` | `"local_checkout" \| "worktree" \| "directory"` | | +| `displayName` | `string` | | +| `createdAt` | `string` (ISO 8601) | | +| `updatedAt` | `string` (ISO 8601) | | +| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete; required nullable | --- @@ -385,7 +393,20 @@ Array of workspace records. A workspace is a specific working directory within a } ``` -Simple set of Expo push notification tokens. No schema validation — just an array of strings. +Simple set of Expo push notification tokens. Loaded with permissive parsing (filters non-string entries). Persisted with atomic temp-file rename. + +--- + +## 9. Daemon meta files + +These small files are not validated as full Zod schemas but are persisted under `$PASEO_HOME` for daemon identity and runtime coordination. + +| Path | Format | Notes | +| --------------------- | -------------------------------------------------------------- | --------------------------------------------------------------------------------- | +| `server-id` | Plain text, e.g. `srv_` | 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`. | --- diff --git a/docs/design.md b/docs/design.md index 815e1cf64..0c786b1c1 100644 --- a/docs/design.md +++ b/docs/design.md @@ -32,7 +32,7 @@ Hierarchy is conveyed through weight and color, not size. Most labels, titles, a Weight has three tiers, applied by role: -- **Screen titles** — the title at the top of a screen — use `` (`packages/app/src/components/headers/screen-title.tsx:31-34`), which renders `fontSize.base` at weight `400` on compact and `300` on desktop. Top-of-screen titles are lighter on desktop, not heavier. The workspace screen header follows the same rule (`packages/app/src/screens/workspace/workspace-screen.tsx:3052-3057`). +- **Screen titles** — the title at the top of a screen — use `` (`packages/app/src/components/headers/screen-title.tsx`), which renders `fontSize.base` at weight `400` on compact and `300` on desktop. Top-of-screen titles are lighter on desktop, not heavier. The workspace screen header follows the same rule (`packages/app/src/screens/workspace/workspace-screen.tsx`). - **Structural labels** use `fontWeight.medium`. This applies to section labels above a stack of rows (`packages/app/src/components/agent-list.tsx:519-523`, `packages/app/src/components/keyboard-shortcuts-dialog.tsx:63-67`), form field labels above an input inside a modal (`packages/app/src/components/add-host-modal.tsx:19-23`, `packages/app/src/components/pair-link-modal.tsx:24-28`), the title at the top of a modal/sheet/dialog (`packages/app/src/components/adaptive-modal-sheet.tsx:90-94`, `packages/app/src/components/ui/combobox.tsx:1607-1611`, `packages/app/src/components/welcome-screen.tsx:48-53`), action button labels in tight components such as the sidebar callout actions (`packages/app/src/components/sidebar-callout.tsx:218-221`), and inline data emphasis on dense metadata rows (`packages/app/src/components/git-diff-pane.tsx:2322-2327`, `packages/app/src/components/file-explorer-pane.tsx:1115-1122`). - **Content** uses `fontWeight.normal`. This applies to settings rows (`packages/app/src/styles/settings.ts`), sidebar primary list-item titles (`packages/app/src/components/sidebar-workspace-list.tsx:2680-2686`, `packages/app/src/components/agent-list.tsx:572-578`), `