Files
paseo/docs/data-model.md
Mohamed Boudra 71b5c35d9b Run multiple independent workspaces per directory (#1539)
* feat(workspace): bind agents/terminals/status to workspaceId (Model B phase 1)

Model B EX-Phase 1: the binding layer. Records gain an additive
workspaceId that answers "which workspace does this belong to" without
replacing the existing required cwd.

- protocol: add optional workspaceId to agent/terminal records and a
  workspaceOwnership capability under server_info.features.*
- server: resolve and persist workspaceId on agent and terminal
  creation; carry it through agent-manager, projections, storage,
  loading, sessions, and the terminal manager/worker pipeline
- workspace-directory / workspace-registry-model: derive and expose
  workspace ownership; bind status to workspaceId
- client: surface workspaceId via daemon-client
- app: filter terminals and agent visibility by workspaceId, thread it
  through workspace screen / terminal panel / session store
- tests: cover workspaceId binding across create, agent-manager,
  mcp-server, workspace-directory, workspace-registry-model, and
  agent-visibility

cwd stays required on every record; workspaceId is purely additive and
back-compatible (optional/defaulted), so old and new peers still parse
each other's messages.

Known follow-ups: none.

* feat(workspace): empty projects + editable titles, fix same-cwd terminal isolation (Model B phase 2)

- Address Phase 1 review fixes and add same-cwd terminal isolation e2e coverage
- Isolate terminal subscriptions per workspaceId so two workspaces sharing one cwd no longer cross-wire terminals (terminal-subscription-key)
- Add editable workspace title, decoupled from the backing directory/branch
- Persist empty projects (no workspaces yet) across daemon restarts
- Add e2e coverage: empty-project-persists (server + app), workspace-same-cwd-isolation, sidebar-workspace-rename

Known follow-ups: none

* feat(workspace): create multiple workspaces per directory — local or worktree (Model B phase 3)

- workspace.create RPC always creates a new workspace record; no directory dedup
- dropped directory dedup in open_project and createPaseoWorktree paths
- non-git directories are first-class: local-checkout workspaces no longer require a repo
- service-proxy collision resolved by defaulting to the owning workspace instead of failing
- new creation UI: choose backing directory (local checkout or worktree) per workspace
- e2e coverage for creating multiple workspaces over the same directory

Known follow-ups:
- COMPAT cwd->workspaceId resolver remains until client floor advances
- prune any remaining workspace==directory assumptions surfaced by usage

* feat(cli): paseo run workspace policy — bare run creates a workspace, --workspace/PASEO_WORKSPACE_ID target (Model B phase 4)

- A workspace is now the explicit home of a `paseo run`: run.ts resolves a
  workspace before creating any agent, then stamps the agent with that
  workspaceId — no run leans on createAgent's legacy cwd->workspace fallback.
- Precedence: --workspace <id> > $PASEO_WORKSPACE_ID > --worktree > bare run.
  --worktree mints its own workspace and overrides the ambient
  PASEO_WORKSPACE_ID; --worktree + --workspace is rejected upstream.
- New-workspace-per-bare-run: every bare run mints a fresh local-backed
  workspace for its cwd rather than reattaching to an existing one in that dir.
- Env support: $PASEO_WORKSPACE_ID (exported by workspace terminals) targets an
  existing workspace, same as --workspace.
- Help text: new --workspace option documents the default (new workspace per
  run) and the env fallback; created runs print the workspace id plus a tip.
- e2e: cli-run-workspace-precedence.e2e.test.ts covers bare, distinct-per-cwd,
  --workspace attach, and env attach against an isolated daemon.

Known follow-ups:
- createAgent's COMPAT cwd->workspace resolver stays for old clients; the CLI no
  longer relies on it but it is not yet removable.

* feat(workspace): archive removes the workspace record, never the directory; last worktree ref offers disk delete (Model B phase 5)

- Archive is now scoped to a single workspace RECORD (by workspaceId), not by cwd/worktree path. It tears down only the agents and terminals owned by the target workspaceId.
- Sibling isolation: a directory can back multiple workspaces, so archiving one workspace no longer destroys a sibling workspace's agents, terminals, or directory.
- Decoupled on-disk worktree deletion into an explicit, last-reference-only option (deleteWorktreeFromDisk). The directory is removed only when the archived workspace was the last active reference to a Paseo-owned worktree; local checkouts are never deleted.
- Unified archive UI with a keep/delete prompt: "Keep on disk" is the default non-destructive choice, "Delete" removes the worktree directory.
- Protocol: new optional, defaulted deleteWorktreeFromDisk field (COMPAT-tagged, back-compat preserved).
- Tests: record-scoped archive e2e (sibling isolation, last-ref disk delete, sibling-keeps-directory) and an app e2e for the keep prompt.

Known follow-ups:
- Surface the keep/delete prompt's "delete" path coverage in app e2e (only the keep path is exercised today).
- COMPAT(worktreeDiskDeletion): drop the optional gate when floor >= v0.1.97.

* feat(workspace): uniform expandable projects + status inbox; merged worktrees cleaned, explicit-id archive (Model B phase 6)

- P5 archive fixes: worktree archive now targets an explicit workspaceId, and auto-archive-on-merge cleans the worktree directory from disk when it is the last reference.
- Dropped non-git project flattening: every project — git or non-git, single- or multi-workspace — renders as the same expandable parent.
- Every project is expandable and every workspace is archivable; each carries its own "+ New workspace" affordance regardless of kind.
- Added a status inbox grouping (Ready for review / Working / Done) where each workspace is bucketed independently by last update.
- The deepest sidebar level is the workspace row: tabs, agents, and terminals never appear in the sidebar.
- Added Model B sidebar e2e coverage proving the expandable-parent, no-leaf, and independent-status-bucketing invariants.

Known follow-ups: the inline header FolderPlus worktree shortcut stays git-only (canCreateWorktreeForProjectKind); non-git projects create workspaces via the "+ New workspace" row.

* fix(protocol): use literal :: separator in terminal subscription key

The committed blob contained a NUL byte as the separator instead of the
:: the comment documents, which broke text diffs and review tooling
(git flagged the file as binary). Functionally the key is an in-memory
Map key only, but source must stay text.

* feat(workspace): shared directory right-sidebar boundary + docs; final consolidation (Model B phase 7)

- Pin and document the directory-backed vs workspace-owned right-sidebar boundary:
  same-cwd workspaces share directory-keyed git/PR/file surfaces but never share
  workspace-owned drafts, attachments, or file-explorer state. New docs section in
  architecture.md plus data-model.md keying convention and two glossary terms.
- Add e2e coverage for the boundary: same-directory-workspaces.spec.ts proves the
  right sidebar is shared (a directory change appears in both same-dir workspaces)
  while tabs/terminals stay independent per workspace.
- Accumulated follow-up fixes:
  - MCP child agents created in a new worktree are stamped with the new worktree's
    workspaceId (not the parent's, not unstamped), mirroring the session path so
    workspaceId-scoped archive can find and tear them down.
  - session-store mergeWorkspaces prunes a stale empty-project descriptor when a
    workspace lands in that project, so it stops governing the project's metadata.
  - CLI run: reject --worktree alongside an ambient PASEO_WORKSPACE_ID (not just
    --workspace), so worktree resolution never races an existing-workspace select.
    New run.test.ts pins the three validation outcomes.
- Full-branch reshape outcomes:
  - Consolidate the four placeholder server_info.features flags (workspaceOwnership,
    workspaceTitles, workspaceProjects, workspaceMultiplicity) into the single
    workspaceMultiplicity capability gate.
  - Remove the now-dead ensureLocalCheckoutWorkspace and its deps interface; explicit
    creation always mints a new same-cwd record via createLocalCheckoutWorkspace.
  - Resolve every Model B COMPAT marker from v0.1.X to v0.1.97.
  - Drop the obsolete workspaceOwnership feature assertion from the same-cwd
    isolation e2e; broaden a sidebar-shortcuts test to cover both project kinds.
  - Add workspace-create-errors.e2e.test.ts pinning each early-reject error branch.
- typecheck, lint, format all green.

Known follow-ups: pre-existing COMPAT(rewind) markers still carry v0.1.X placeholders
(out of Model B scope, left untouched).

* test(workspace): de-slop Model B tests per audit-tests (reshape mocks/assertions)

Reshape Model B test changes flagged by audit-tests so they exercise real
behavior and observable state instead of mock scaffolding.

Categories fixed:

- Mocks -> real dependencies + persisted state: create.test.ts and
  mcp-server.test.ts dropped hand-built AgentManager/AgentStorage mock objects
  and mock.calls[...] assertions, now run the real AgentManager/AgentStorage
  with a fake agent client and assert on the stored agent record (workspaceId,
  parent label).

- Module-internal spying -> injected seam: acp-agent stopped spying on the
  tree-kill module (vi.spyOn(treeKillModule, ...)). Added a ProcessTerminator
  injection seam to ACPAgentClient/ACPAgentSession (production defaults to
  terminateWithTreeKill); tests inject a typed FakeTerminator and assert on the
  recorded children plus observable stream .destroyed state.

- Poking internals -> public API: acp close()/killTerminal/releaseTerminal
  tests now create terminals through the public createTerminal API instead of
  mutating internals.terminalEntries.

- Reimplemented production logic deleted: cli-run-workspace-precedence dropped
  its inline copy of resolveRunWorkspace flag precedence (a fake reimplementation
  it then asserted against) and now proves only the daemon behaviors the CLI
  builds on; flag precedence stays covered in the CLI's own run.test.ts.

- Sleep -> poll: workspace-same-cwd-isolation replaced a fixed setTimeout with
  expect.poll on the observed snapshot state to remove the race.

- Internal protocol-frame assertions removed: sidebar-workspace-rename dropped
  the captureWsSessionFrames workspace.title.set.request assertions, relying on
  the user-visible rename + reload checks already present.

* fix: green CI — new-workspace status bucket regression + branch-picker flow tests + sdk emptyProjects

Source regression:
- packages/app/src/screens/new-workspace-screen.tsx
- packages/app/src/screens/new-workspace-empty.ts
  When a new workspace is created with an initial agent, optimistically merge it
  with status "running" (statusEnteredAt now) so it lands in the "Working" bucket
  instead of defaulting into the wrong bucket. The empty-workspace path passes
  withInitialAgent: false so a bare workspace keeps its descriptor status.

Test-drift (intentional P3 flow change — backing picker now required):
- packages/app/e2e/new-workspace.spec.ts
- packages/app/e2e/helpers/new-workspace.ts
  The reshaped creation flow requires choosing a backing ("New worktree") before
  the branch / starting-ref picker is reachable. Branch-picker specs now call
  selectWorkspaceBacking(page, "worktree") first, and the helper waits for the
  worktree control to drop aria-disabled (it stays disabled until the checkout
  status query confirms the project is a git repo) before clicking.

Test-drift (protocol field added — sdk emptyProjects):
- packages/client/src/index.test.ts
  fetch_workspaces response now carries emptyProjects; the toEqual expectation
  includes emptyProjects: [].

Flake hardening:
- packages/app/e2e/same-directory-workspaces.spec.ts
- packages/app/e2e/helpers/seed-client.ts
  Out-of-band working-tree writes raced the daemon's debounced filesystem
  watcher, so the UI could subscribe before the new file was in the git snapshot.
  Force a checkout refresh (same path as the UI's manual refresh) to make the
  write authoritative before asserting, removing the timing dependency.

* test(app): expect withInitialAgent:false in empty-workspace create call

The withInitialAgent flag (fix for the new-workspace Done-bucket regression)
added a field to the ensureWorkspace call; the empty-path unit test pinned the
exact args. Test-drift, not behavior — update the expectation.

* feat(app): stack new-workspace creation params (Project / Isolation dropdown / Base) with reserved base row + keyboard avoidance

Restructure the new-workspace creation screen into a vertical stack of
Project / Isolation / Base parameters instead of the previous mixed
layout.

- Isolation/backing is now a dropdown (Local vs New worktree) matching the
  app's existing dropdown/combobox primitives, replacing the inline
  segmented switcher.
- The Base (starting ref) row now reserves its space even when the backing
  is Local and the picker is hidden, so switching backing no longer causes
  the form to shift vertically.
- The form avoids the keyboard so the title input and submit stay visible
  while typing on native and web.
- Updated the e2e new-workspace helper to drive the dropdown-based
  backing selector and the reserved Base row.

* fix(app): simplify new-workspace form — stacked ghost rows above composer, no card/title, project-matched dropdowns

- Render the three rows (formStack) at the top, under the "New workspace"
  heading and above the composer input, not in the composer footer.
- Drop the card/surface/border chrome; rows sit on the plain background.
- Stack Project, Isolation (multiplicity only), and Base; remove the title
  field — the title is set server-side and create works with no user title.
- Each row is a Label immediately followed by its dropdown control; no
  columns, fixed label widths, or reserved horizontal space, with the label
  glyph aligned to the heading's text x.
- All three triggers share the ghost badge style of the Project control
  (ProjectPickerTrigger / IsolationPickerTrigger / RefPickerTrigger +
  Combobox); keep the workspace-create-backing-* test IDs.
- Omit "New worktree" from Isolation entirely when the project is non-git.
- Reserve the Base row height always but render nothing on Local backing,
  showing the Base label + ref picker only for New worktree.
- Keep keyboard avoidance on the centered content.
- E2E: drive selectWorkspaceBacking via the Isolation trigger + Combobox and
  remove the now-gone workspace-create-title-input usage and title field.

* feat(app): sidebar new-workspace entry points + non-git isolation hidden + project auto-select

- Q1: hide the Isolation control on non-git projects — gate the row on canCreateWorktree (multiplicity && selectedIsGit) so a project with no git checkout never offers a worktree backing choice.
- Q2: reset the stale project preselect across the reused 'new' screen — clear the manual picker choice on route project identity change so each route-driven navigation preselects its own project; align the nav verb to router.navigate.
- Q3: remove the per-project "+ New workspace" sidebar row and add one global "New workspace" entry above Sessions in both mobile and desktop sidebars (testID sidebar-global-new-workspace); creation stays reachable per-project via the existing git new-worktree icon.
- Q4: match the Sessions / New-workspace header button sizing to the workspace rows — SidebarHeaderRow icon md->sm, label fontSize base->sm.
- e2e: new new-workspace-entry.spec covering global entry, project preselect reset across reused screen, and non-git isolation hidden; update sidebar-model-b, empty-project-persists, workspace-multiplicity, and helpers for the removed per-project row.

* fix(app): group New-workspace/Sessions header (no divider, workspace-row sized) + remove banned useUnistyles

- Q5: wrap the New-workspace and Sessions header entries in a single sidebarHeaderGroup that owns one bottom divider, so the two rows sit tight together with no gap and no per-row separator (both mobile and desktop sidebars).
- Add a compact variant to SidebarHeaderRow: workspace-row sized (minHeight 36, surfaceSidebarHover, borderRadius.lg) with horizontal padding that aligns its icon/label with the Workspaces section title and the workspace rows below; the default header variant (settings Back-to-workspace) keeps its sidebar-height row and own separator.
- Remove the banned useUnistyles() from sidebar-header-row.tsx per docs/unistyles.md: theme-reactive icon color now goes through withUnistyles(Icon) + uniProps mappings; static sizing reads ICON_SIZE.
- Taste: new-workspace-screen dedupes the project-icon styles (single projectIcon/projectIconFallback/projectIconFallbackText) and replaces repeated magic numbers with BADGE_HEIGHT and a named fallback-font-size constant.

* fix(app): no layout shift on git<->non-git (reserve Isolation row) + symmetric sidebar divider spacing

- Q6: Isolation row reserves its height and renders an invisible spacer for non-git projects, matching the Base-row pattern, so switching between git and non-git projects keeps a constant form height with no layout shift.
- Q7: sidebar header group splits paddingVertical into paddingTop/paddingBottom so the Sessions-row-to-divider gap equals the divider-to-Workspaces-header gap, centering the divider.

* feat(app): rename Sessions to History (clock icon, Agent history header)

- Sidebar label now reads "History" with a clock icon (was Sessions / MessagesSquare)
- Sessions screen header now reads "Agent history"
- Updated i18n strings across all 6 locales (en, ar, ru, zh-CN, fr, es)
- Route and testIDs unchanged (sidebar-sessions, /sessions)

* fix(app): symmetric sidebar header padding (top == bottom), traffic-light inset preserved

The sidebarHeaderGroup wrapper (New-workspace + History rows) had paddingTop: theme.spacing[1] against paddingBottom: theme.spacing[2]. Equalize paddingTop to theme.spacing[2] so the header group's top padding matches its bottom padding. The divider stays centered since paddingBottom still matches WorkspacesSectionHeader's paddingTop. The desktop window-controls (traffic-light) spacer — paddingTopSpacerStyle plus TitlebarDragRegion — is a separate inset and is left untouched.

* fix(app): hover card shows branch; sidebar copy-branch copies the real branch (not the title)

- Add SidebarWorkspaceEntry.currentBranch, sourced from gitRuntime.currentBranch (normalized; detached HEAD/blank/missing -> null)
- Fix copy-branch smear: handleCopyBranchName copied workspace.name (the title); now copies the real currentBranch (guarded)
- Workspace hover card: add a branch row (GitBranch icon + branch name), shown only when it differs from the title
- Header branch-switcher left untouched; diff-pane re-home decision still pending

* feat(app): branch switcher moves into the git diff panel; header title is static (Model B coherence)

- branch switcher now lives in the diff panel Changes header on desktop+mobile
- workspace header title is a plain static title; branch removed from it
- no new workspace-screen header row
- git diff-stat unchanged, stays where it is
- no duplicate git actions
- unified descriptor name fallbacks via resolveWorkspaceName
- rename-then-switch e2e proves header title and real branch stay independent
2026-06-15 14:23:02 +08:00

32 KiB

Data Model

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).


Directory layout

$PASEO_HOME/
├── config.json                          # Daemon configuration
├── server-id                            # Stable daemon identifier (plain text, "srv_<base64url>")
├── daemon-keypair.json                  # E2EE keypair for relay (mode 0600)
├── paseo.pid                            # Daemon PID lock file
├── daemon.log                           # Default log file (path configurable)
├── agents/
│   └── {sanitized-cwd}/
│       └── {agentId}.json               # One file per agent
├── schedules/
│   └── {scheduleId}.json                # One file per schedule
├── chat/
│   └── rooms.json                       # All rooms + messages
├── loops/
│   └── loops.json                       # All loop records
├── projects/
│   ├── projects.json                    # Project registry
│   └── workspaces.json                  # Workspace registry
└── 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). Persistent server stores write atomically by writing a temp file in the target directory and then renaming it into place.


1. Agent Record

Path: $PASEO_HOME/agents/{project-dir}/{agentId}.json

Each agent is stored as a separate JSON file, grouped by project directory.

Field Type Description
id string UUID, primary key
provider string Agent provider ("claude", "codex", "opencode", etc.)
cwd string Working directory the agent operates in
createdAt string (ISO 8601) Creation timestamp
updatedAt string (ISO 8601) Last update timestamp
lastActivityAt string? (ISO 8601) Last activity timestamp
lastUserMessageAt string? (ISO 8601) Last user message timestamp
title string? User-visible title
labels Record<string, string> Key-value labels (default {}). paseo.parent-agent-id set automatically when launched via the create_agent MCP tool — see agent-lifecycle.md
lastStatus AgentStatus One of: "initializing", "idle", "running", "error", "closed"
lastModeId string? Last active mode ID
config SerializableConfig? Agent session configuration (see below)
runtimeInfo RuntimeInfo? Live runtime state (see below)
features AgentFeature[]? Provider-reported features (toggles/selects)
persistence PersistenceHandle? Handle for resuming sessions
lastError string? (nullable) Last error message, if any
requiresAttention boolean? Whether the agent needs user attention
attentionReason "finished" | "error" | "permission"? Why attention is needed
attentionTimestamp string? (ISO 8601) When attention was flagged
internal boolean? Whether this is a system-internal agent (loop workers, etc.)
archivedAt string? (ISO 8601) Soft-delete timestamp

Nested: SerializableConfig

Field Type Description
title string? Configured title
modeId string? Configured mode
model string? Configured model
thinkingOptionId string? Thinking/reasoning level
featureValues Record<string, unknown>? Feature preference overrides
extra Record<string, any>? Provider-specific config
systemPrompt string? Custom system prompt
mcpServers Record<string, any>? MCP server configurations

Nested: RuntimeInfo

Field Type Description
provider string Active provider
sessionId string? Active session ID
model string? Active model
thinkingOptionId string? Active thinking option
modeId string? Active mode
extra Record<string, unknown>? Provider-specific runtime data

Nested: PersistenceHandle

Field Type Description
provider string Provider that owns the session
sessionId string Session ID for resumption
nativeHandle any? Provider-specific handle (Codex thread ID, Claude resume token, etc.)
metadata Record<string, any>? Extra metadata

Nested: AgentFeature (discriminated union on type)

Toggle:

Field Type
type "toggle"
id string
label string
description string?
tooltip string?
icon string?
value boolean

Select:

Field Type
type "select"
id string
label string
description string?
tooltip string?
icon string?
value string | null
options AgentSelectOption[]

2. Daemon Configuration

Path: $PASEO_HOME/config.json

Single file, validated with PersistedConfigSchema.

{
  version: 1,
  daemon: {
    listen: "127.0.0.1:6767",
    hostnames: true | string[],   // legacy alias `allowedHosts` is migrated on load
    mcp: { enabled: boolean, injectIntoAgents: boolean },
    appendSystemPrompt: string,    // appended to supported provider system/developer prompts
    cors: { allowedOrigins: string[] },
    relay: { enabled: boolean, endpoint: string, publicEndpoint: string, useTls: boolean, publicUseTls: boolean },
    auth: { password: string }    // bcrypt hash, optional
  },
  app: {
    baseUrl: string
  },
  worktrees?: {
    root?: string            // optional root for new worktrees; defaults to $PASEO_HOME/worktrees
  },
  providers: {
    openai: { apiKey: string },
    local: { modelsDir: string }
  },
  agents: {
    // 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<providerId, ProviderOverride>,
    metadataGeneration: {
      providers: [{ provider, model?, thinkingOptionId? }]
    }
  },
  features: {
    dictation: { enabled, stt: { provider, model, language, confidenceThreshold } },
    voiceMode: { enabled, llm, stt: { provider, model, language }, turnDetection, tts: { provider, model, voice, speakerId, speed } }
  },
  log: {
    level, format,
    console: { level, format },
    file: { level, path, rotate: { maxSize, maxFiles } }
  }
}

All fields are optional with sensible defaults.

agents.metadataGeneration.providers controls the preferred structured-generation fallback order for daemon-side metadata tasks such as commit messages, PR text, branch names, and generated agent titles. Entries are tried first in the configured order, then Paseo falls through to dynamically discovered defaults and finally the current selection when available.

Local speech model ids are intentionally narrow: STT uses parakeet-tdt-0.6b-v2-int8, TTS uses kokoro-en-v0_19, and turn detection uses the bundled Silero VAD model.


3. Schedule

Path: $PASEO_HOME/schedules/{id}.json

One file per schedule. ID is 8 hex characters.

Field Type Description
id string 8-char hex ID
name string? Human-readable name
prompt string The prompt to send
cadence ScheduleCadence Timing (see below)
target ScheduleTarget What to run (see below)
status "active" | "paused" | "completed" Current state
createdAt string (ISO 8601)
updatedAt string (ISO 8601)
nextRunAt string? (ISO 8601) Next scheduled execution
lastRunAt string? (ISO 8601) Last execution time
pausedAt string? (ISO 8601) When paused
expiresAt string? (ISO 8601) Auto-expire time
maxRuns number? Max executions before completing
runs ScheduleRun[] Execution history

Nested: ScheduleCadence (discriminated union on type)

  • { type: "every", everyMs: number } — interval in milliseconds
  • { type: "cron", expression: string, timezone?: string } — cron expression; absent timezone means UTC, present timezone is an IANA time zone used for local wall-clock recurrence

Nested: ScheduleTarget (discriminated union on type)

  • { type: "agent", agentId: string } — send to existing agent
  • { type: "new-agent", config: { provider, cwd, modeId?, model?, thinkingOptionId?, title?, approvalPolicy?, sandboxMode?, networkAccess?, webSearch?, extra?, systemPrompt?, mcpServers? } } — create a new agent

Nested: ScheduleRun

Field Type Description
id string Run ID
scheduledFor string (ISO 8601) Intended execution time
startedAt string (ISO 8601)
endedAt string? (ISO 8601)
status "running" | "succeeded" | "failed"
agentId string? (UUID) Agent used for this run
output string? Agent output text
error string? Error message if failed

4. Chat

Path: $PASEO_HOME/chat/rooms.json

Single file containing all rooms and messages.

{
  "rooms": [ ... ],
  "messages": [ ... ]
}

ChatRoom

Field Type Description
id string (UUID)
name string Unique room name (case-insensitive)
purpose string? Room description
createdAt string (ISO 8601)
updatedAt string (ISO 8601) Updated on each new message

ChatMessage

Field Type Description
id string (UUID)
roomId string FK to ChatRoom.id
authorAgentId string Agent ID of the author
body string Message text (supports @mentions)
replyToMessageId string? FK to another ChatMessage.id
mentionAgentIds string[] Extracted @mention agent IDs
createdAt string (ISO 8601)

5. Loop

Path: $PASEO_HOME/loops/loops.json

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
id string 8-char UUID prefix
name string? Human-readable name
prompt string Worker prompt
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
sleepMs number Delay between iterations (ms)
maxIterations number? Cap on iterations
maxTimeMs number? Total time budget (ms)
status "running" | "succeeded" | "failed" | "stopped"
createdAt string (ISO 8601)
updatedAt string (ISO 8601)
startedAt string (ISO 8601)
completedAt string? (ISO 8601)
stopRequestedAt string? (ISO 8601)
iterations LoopIteration[]
logs LoopLogEntry[]
nextLogSeq number Monotonic log sequence counter
activeIteration number? Currently executing iteration index
activeWorkerAgentId string? Currently running worker agent
activeVerifierAgentId string? Currently running verifier agent

Nested: LoopIteration

Field Type Description
index number 1-based iteration index
workerAgentId string? Agent ID of the worker
workerStartedAt string (ISO 8601)
workerCompletedAt string? (ISO 8601)
verifierAgentId string? Agent ID of the verifier
status "running" | "succeeded" | "failed" | "stopped"
workerOutcome "completed" | "failed" | "canceled"?
failureReason string?
verifyChecks LoopVerifyCheckResult[] Shell check results
verifyPrompt LoopVerifyPromptResult? LLM verification result

Nested: LoopLogEntry

Field Type
seq number (monotonic)
timestamp string (ISO 8601)
iteration number?
source "loop" | "worker" | "verifier" | "verify-check"
level "info" | "error"
text string

Nested: LoopVerifyCheckResult

Field Type
command string
exitCode number
passed boolean
stdout string
stderr string
startedAt string (ISO 8601)
completedAt string (ISO 8601)

Nested: LoopVerifyPromptResult

Field Type
passed boolean
reason string
verifierAgentId string?
startedAt string (ISO 8601)
completedAt string (ISO 8601)

6. Project Registry

Path: $PASEO_HOME/projects/projects.json

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 | null (ISO 8601) Soft-delete timestamp; required nullable

Active git projects are unique by normalized rootPath. Startup reconciliation repairs older bad states by moving workspaces from duplicate path-keyed projects onto the canonical project, preferring remote-keyed project IDs such as remote:github.com/owner/repo, then archiving the emptied duplicate.


7. Workspace Registry

Path: $PASEO_HOME/projects/workspaces.json

Array of workspace records. A workspace is a specific working directory within a project.

Field Type Description
workspaceId string Opaque stable identifier (wks_<hex>), generated independently of the directory. MUST NOT be treated as a path; compare by exact equality. Use the cwd field for directory access.
projectId string FK to Project.projectId
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

Opaque-ID invariant: workspaceId is opaque identity, never a filesystem path. Filesystem and git operations take cwd/workspaceDirectory only — never the id. Path-derived grouping keys (e.g. deriveWorkspaceDirectoryKey, used at bootstrap to group agents into a workspace) are directory keys, not workspace identity, and must not be persisted or compared as ids.


8. Push Token Store

Path: $PASEO_HOME/push-tokens.json

{
  "tokens": ["ExponentPushToken[...]", ...]
}

Simple set of Expo push notification tokens. Loaded with permissive parsing (filters non-string entries). Persisted with atomic temp-file rename.


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_<base64url> Stable per-$PASEO_HOME daemon ID. Overridable via PASEO_SERVER_ID env.
daemon-keypair.json { v: 2, publicKeyB64, secretKeyB64 } (libsodium box keypair) E2EE relay identity. Written with mode 0600. Regenerated if file is unreadable.
paseo.pid JSON { pid, startedAt, ... } PID lock; prevents two daemons sharing one $PASEO_HOME.
daemon.log Pino log output Default location; path/rotation configurable via log.file in config.json.

Client-side stores (App)

These live in React Native AsyncStorage or browser IndexedDB, not on the daemon filesystem.

Keying convention: directory-backed vs workspace-owned

Right-sidebar client state splits on whether it is determined by the directory or owned by the workspace (two workspaces can share one cwd). The split is enforced by the cache key, so changing a key changes the sharing semantics — see architecture.md for the full table.

  • Directory-backed (shared by same-cwd workspaces): keyed by (serverId, cwd). Git status/diff, GitHub PR status, PR timeline, file preview content. These are TanStack Query caches, not persisted stores.
  • Workspace-owned (independent per workspace): keyed by workspaceId, with cwd used only as a fallback when no workspaceId is present. Review draft comments (@paseo:review-draft-store), diff-mode overrides (in-memory), workspace composer attachments, and file-explorer nav/expand state. The workspaceId part of these keys is opaque — never parse it back into a path.

Draft Store

AsyncStorage key: paseo-drafts (version 2)

{
  drafts: Record<draftKey, {
    input: { text: string, images: AttachmentMetadata[] },
    lifecycle: "active" | "abandoned" | "sent",
    updatedAt: number,     // epoch ms
    version: number        // optimistic concurrency
  }>,
  createModalDraft: DraftRecord | null
}

Attachment Store (Web)

IndexedDB database: paseo-attachment-bytes, object store: attachments

Stores binary attachment blobs keyed by attachment ID.

AttachmentMetadata

Field Type Description
id string Unique attachment ID
mimeType string MIME type
storageType string Storage backend identifier
storageKey string Key within the storage backend
createdAt number Epoch ms
fileName string? Original filename
byteSize number? Size in bytes