Files
paseo/docs/architecture.md
T
c779ef06c5 feat(workspaces): add workspace labels (#3510)
* feat(app): workspace labels baseline

Existing labels implementation, committed as-is as the baseline for the
UI redesign. Server, protocol, catalog store and sync are sound; the UI
layer is reworked in the commits that follow.

Co-authored-by: Christoph Leiter <christoph@leiter.io>

* feat(app): show workspace labels on the sidebar meta line

Labels could be assigned and then appeared nowhere. They now draw as a
chip on the workspace meta row, after the host, change request, checks
and service, and they can be switched off from the Show submenu like any
other item on that line.

One palette, not two. `baseColors.workspaceLabel` was ten raw Tailwind
500s spanning a 1.9:1 to 4.5:1 contrast range — the exact set
identity-colors.ts was written to replace. Label color is now the
identity color of the same name, so a chip, a host badge and a CI check
on one row sit in one contrast band. `workspace-labels/colors.ts` and its
per-file `labelColorViolet` blocks are gone; `chip.tsx` and `swatch.tsx`
are the only places a label color becomes ink.

A row resolves its labels against its own host's catalog and nothing
else. The cross-host projection answers too, but two hosts may give one
name two colors and the merge settles that by target precedence and then
alphabetical serverId — a row painted in a color its host never assigned
is lying about the one thing the color is for.

The meta row's doc comment argued against tinted chrome on that line. The
chip is a deliberate exception — everything else there reports state and
shares the state colors, a label is a name a person chose — so the
comment now says that instead of contradicting the code.

Alignment:
- The chip is exactly as tall as the line: text at the row's 16pt
  lineHeight, no vertical padding, so a labelled row is the same height
  as an unlabelled one.
- A label run that opens the line hangs left by the chip's own padding,
  landing the name on the rail the title above it uses. Mid-line the
  ground stays in the flow.
- The swatch row reaches 44pt through hitSlop growing outward from a 20pt
  swatch, replacing ten 44pt colored blobs in the manager modal.
- The picker's raw `<Plus>`, `<CheckSquare>` and `<Square>` go through
  withUnistyles with module-level mappings, at the menu's 14pt.

Also fixes a bug this change exposed: `SidebarRowItemsSchema` is a strict
object, so adding an item without a key there fails the whole settings
write and silently loses every other Show toggle with it. A test now
covers each item in SIDEBAR_ROW_ITEMS.

Phase 1 of the workspace labels redesign. The filter menu, the picker's
interaction model and the manager modal's layout are later phases.

Co-authored-by: Christoph Leiter <christoph@leiter.io>

* feat(app): one Labels filter page with tri-state rows

Filtering by label cost three levels: Display -> Labels -> Include -> a page
of labels, or -> Exclude -> the same page again. It is now one page: every
label across every host, one row each, leading with its colour dot.

A row cycles off -> include -> exclude -> off. Include is MenuItem's own
check; exclude is lucide's X, the check's own pair, at the same 16 in the
same trailing slot, so the column does not shift as a row cycles. Exclude
also dims the row, because a state you have to identify by its glyph is not
one you read at a glance. Unlabelled is a row in the same list, with a hollow
ring where the others carry their colour.

Match any / all still appears only once two labels are included. Clear is
absent, not disabled, when nothing is filtered. Manage labels sits at the
bottom below a separator. The whole root row is gone when no host has a
label — unless a filter is still active, since the merged catalog only counts
online hosts and losing one would otherwise strand a filter that is still
hiding workspaces.

The page is titled Labels, so its rows stop repeating the word: "Match any",
"Match all", "Clear filter". The two Match rows sit directly under the list
they qualify and only appear once two labels are included, so the subject is
already on screen. All nine locales move together, translated rather than
transliterated — Arabic keeps its noun, where "أي" is a determiner that needs
one to stay grammatical.

SidebarLabelFilter collapses from four collections plus match to one
Record<labelKey, "include" | "exclude"> plus match: a tri-state row is one
value per label, so include and exclude can no longer disagree about the same
name. Unlabelled keys off the empty string, which normalizeWorkspaceLabelName
makes unreachable for a real label. toggleSidebarLabelSelection,
countSidebarLabelSelections, sidebarLabelSelectionIncludes and
deduplicateSidebarLabelNames go with the old shape; setLabelFilter becomes
cycleLabelFilter and setLabelMatch, so the store owns its own transitions.

No migration: labels are unreleased, so the old persisted shape never
shipped, and migrateSidebarViewState already resets on a safeParse failure.

The e2e manager step asserted "Red label color" on a swatch that Phase 1
renamed to "Red"; the selector follows the code, and the accessibility.color
key behind the old string is deleted from all nine locales as unreferenced.

Co-authored-by: Christoph Leiter <christoph@leiter.io>

* feat(app): assign labels on the menu's own rails

The assign picker was a search box, a create row that unfolded an inline form
under it, and hand-rolled label rows whose 44x44 checkbox pushed every dot and
name off the rail the rows above them sat on. Nothing lined up with anything.

Rows are `MenuItem` now: the colour dot in the 16pt leading slot, assignment as
the trailing check the engine already draws for a chosen row. Using the
engine's row is the alignment fix; there is no second row implementation to
keep in step.

Every press toggles and the menu stays open — assigning labels is multi-select,
so the dual-target model (`source: "row" | "checkbox"`,
`shouldCloseWorkspaceLabelPicker`) is gone along with the model's `close`
dependency. You leave by dismissing the menu.

Search is deleted. Nobody has enough labels to search, and the field was the
thing pushing the list down the surface. `workspaceLabels.search` and
`createNamed` go with it across all nine locales; `clearSearch` stays because
the manager modal still owns a search field (Phase 4's copy, untouched here).

`Create label` moves below a separator at the bottom and opens a page that
replaces the list: a name field, the swatch row, one commit row. Picking a
colour picks a colour — it no longer creates the label. A failed create keeps
the draft and shows the error on the page; a successful one creates, assigns,
and returns to the list with the row ticked.

Both menus share one page definition. `SidebarWorkspaceMenu` and
`SidebarWorkspaceContextMenu` each built the same `pages` memo; they now call
`useWorkspaceLabelMenuPages`.

Menu engine, all of it forced by putting a text input on a menu page:

- `MenuTextField` — the form kit's `FormTextInput` carries a screen's geometry
  and lands its text 4pt inside the labels above it. This one is drawn as a
  row's own fill, so the box and the text sit on the row's two rails at once.
- `MenuPageDefinition.hoverIntent` — a page you type into is not one the
  pointer opens or dismisses. Without it the create flyout opened on a pointer
  passing over the row, and threw the draft away when the mouse drifted off it
  while the hands were on the keyboard.
- The sheet keeps `keyboardBehavior="interactive"`, not `extend`. With
  `enableDynamicSizing` the largest snap point is the content's own height, so
  a short page did not grow and the keyboard came up over the field. Caught on
  an iOS simulator, fixed, and re-shot.
- `MenuItem` announces `selected` as `aria-checked`. The deleted checkbox said
  so; a check drawn and never announced would have been an accessibility
  regression. Tri-state rows (the filter page's exclude) still cannot announce
  themselves — menu-wide, still Phase 5's.

`sidebar-workspace-row.tsx`'s `isTouchPlatform = platformIsNative || isCompact`
is kept deliberately. On a compact web viewport there is no hover, so the row's
trailing actions and kebab were unreachable and the labels menu with them; this
is the documented `isHovered || isNative || isCompact` shape from CLAUDE.md.

Evidence: /tmp/labels-p3-{wide,compact}-{light,dark}-{list,create}.png, the
create page on iOS with the keyboard up
(/tmp/labels-p3-native-compact-keyboard.png) and the clipped sheet it replaced
(-before.png). `packages/app/e2e/browser/workspace-labels.spec.ts` rewritten
for the new model, daemon-backed failure coverage kept for both the toggle and
the create path, green with the menu unit suites.

Co-authored-by: Christoph Leiter <christoph@leiter.io>

* feat(app): edit a label in one operation

`workspace.label.rename.request` and `workspace.label.recolor.request` become
one `workspace.label.update.request { name, newName?, color? }`. Both old RPCs
are deleted outright — no COMPAT shim, because the labels feature is unreleased
and neither name has ever been on a wire a user owns. The `workspaceLabels`
capability flag stays; that one is how a new app detects an old daemon.

Two calls could land half-applied, which left the catalog in a state nobody
asked for and the UI with nothing true to say. One catalog commit now applies
the name, the colour, or both. Renaming onto a name the host already has is
rejected rather than merged, and the check runs inside the planner so a refused
collision applies neither field. A label keeping its own key is not colliding
with itself, so case-only edits pass.

The manager modal follows:

- The host selector is `HostFilter`, the pill History and Schedules use.
  `includeAllHost` is now a prop: a surface that edits one host's catalog
  cannot carry out "all".
- Search moves to the sheet header, where it spans the header's own rails.
  As a body control it capped at the component's 420pt and left a hole on the
  right whenever a host filter wasn't beside it. `workspaceLabels.clearSearch`
  goes with the box that had a clear button.
- A row no longer opens an editor. It carries a pencil in its trailing slot,
  hover-revealed on web and always visible on native and compact, on the
  canonical plain-View hover pattern with the press on an inner Pressable.
- The edit view is a name field, the swatch row, and one Save that issues one
  update. Delete keeps its confirm and its affected-workspace count, and moves
  to `outline` — red belongs to the dialog, not to the surface that opens it.
- Copy says edit, never rename or recolor, in all nine locales.

`workspaceLabelErrorMessage` drops the `requestType=… code=…` the daemon client
appends to every RPC failure. That is what a log wants, not what a user reads
under a name field.

`workspaceLabels.accessibility.color` stays deleted: the swatch's accessible
name is already the colour's name, so nothing needed it back.

Co-authored-by: Christoph Leiter <christoph@leiter.io>

* chore(app): converge the labels phases

The sweep at the end of the labels redesign: the three items earlier phases
found and kept out of their own commits, one cross-phase pass, and the docs.

`AdaptiveModalSheet`'s close button reaches its target with `hitSlop` instead
of `padding`, which had inset its glyph 8pt from the rail the title, the search
field and every trailing glyph below it sit on. This is shared chrome, so it
moves the close glyph in every sheet in the app; measured on four of them
across both paths — the labels manager desktop and compact, the shortcuts
dialog, the rename sheet — and each one's trailing rail now agrees with its
leading one.

`MenuItem` takes `selected: boolean | "mixed"`. A tri-state row was silent to a
screen reader: the check announced itself after the last phase, but exclude had
no state at all, so an excluded label was indistinguishable from an untouched
one. `"mixed"` is ARIA's own third state, the engine still draws the check only
for `true`, and the row keeps supplying its own trailing mark — nothing in the
menu engine learns what a label is.

CLAUDE.md's platform-gating rules said "NEVER use `onPointerEnter`/
`onPointerLeave`", which is the opposite of what `docs/hover.md` and
`constants/platform.ts` both mandate. It now names the real rule — hover on a
plain `View`, press on a separate inner `Pressable`, and no `onHoverIn`/
`onHoverOut` on a `Pressable` that contains other pressables — and links the doc
that owns it rather than restating it.

The cross-phase pass turned up one real defect: the replica cache never learned
about labels. `StoredWorkspaceSchema` had no `labels`, so a restored workspace
came back unlabelled, and since the directory cursor is current on reconnect the
daemon has nothing newer to send — the chips stayed gone for the whole session
and the filter matched nothing. An optional field, a serializer line, and an
assertion in the restore spec.

Everything else measured onto its rails already. Left alone and logged: the
per-row `Map` in `useWorkspaceLabelDefinitions`, and the display menu closing
when the sidebar swaps to its empty state, which is confined to that swap and
predates every phase here.

`docs/architecture.md` and `docs/data-model.md` described rename and recolor;
they now describe the one update commit, which fields it rewrites, and the
per-host resolution a row's chips use. `docs/menus.md` documents the third item
state where it already documents the other two.

Co-authored-by: Christoph Leiter <christoph@leiter.io>

* feat(app): split the labels filter into include and exclude controls

The Labels page rotated one row through off -> include -> exclude -> off, which billed the rare
operation to the common one: clearing an include cost two presses and routed through exclude, so
the sidebar visibly re-filtered into a state nobody asked for on the way out.

The row now owns include and a control on its trailing rail owns exclude. Each is one press each
way and neither is reached through the other. `cycleLabelFilter` becomes `toggleLabelInclude` and
`toggleLabelExclude`; the store's map shape is unchanged, and setting either state clears the
other for that label, so including an excluded row is still one press.

A row is never both, so the trailing rail holds exactly one 16pt glyph in every state: the check,
a permanent `X`, or the same `X` revealed on hover. `X` rather than a circle-slash for the reason
Phase 2 settled it — `Ban` fills its box where the check does not, and the column twitches. The
control fades rather than mounts and takes its 44pt target from `hitSlop`, so neither revealing it
nor changing state moves anything: measured 453-469 on every row, wide and compact.

The control carries a tooltip, gated to web where hover exists. It portals into the overlay root
above the flyout with no backdrop of its own, and resting on it does not read as the pointer
leaving the surface, so the menu stays up.

Accessibility: the control is a `checkbox` named "Exclude {label}" with its own checked state, and
it stays in the tree at opacity 0 on rows that have no visible mark, so it is reachable without
hover. Keyboard focus reveals it, so tabbing to it never means pressing something invisible. The
row keeps Phase 5's `selected="mixed"` for exclude.

Co-authored-by: Christoph Leiter <christoph@leiter.io>

* feat(app): put include and exclude on their own rails on every label row

Phase 6 gave the row include and its trailing rail a hover-revealed exclude. The rotation was gone
but the awkwardness was not: `X` is the glyph this app dismisses things with, so on a filter row it
reads as delete rather than exclude, and one rail with a changing occupant still means the row's
trailing area keeps changing identity as you read down the column.

Both controls are now on every row in fixed positions, and state is which one is lit. Nothing
appears, disappears, swaps places or shifts: the check box is 429-445 and the ban box 453-469 on an
included row, an excluded row and an untouched one alike, wide and compact. Because the ban is
always there, include -> exclude is one press instead of two. The row still includes, and the check
is that same action named.

`Ban` rather than `X`. Phase 2 rejected it because its ink fills its 16pt box where a check's does
not, so the column twitched when the two swapped on one rail. Nothing swaps here, so what is left
is a weight difference: the ban is drawn at 14 inside a 16pt box, which is the visible optical
correction available before it stops being a glyph. The box is what the rail is measured from and
it stays 16.

Lit is `foreground`, unlit `foregroundExtraMuted`. Not `foregroundMuted`, which is the obvious
choice and is wrong here specifically: the Match rows further down the same page carry `MenuItem`'s
own check in that colour to mean *selected*, so an unlit check in the same ink would say the
opposite thing eight pixels above it. Excluded keeps Phase 2's dimmed row.

Accessibility: the row keeps `selected` — `true` include, `"mixed"` exclude — and the two buttons
carry no checked state of their own, so a label's state is announced once and its two controls read
as the actions they are. Both buttons take a tooltip, gated to web with `enabledOnDesktop={isWeb}`,
because on non-compact native `TooltipContent` falls back to a `Modal` with its own backdrop.

Deleted with it: the `isHovered || isFocused || isNative || isCompact` reveal, the `opacity: 0`
mounting, the hover-tracking `View` and the focus plumbing that existed to keep an invisible control
reachable. Net -7 lines of code on this row, all of the growth in comments. The store is unchanged;
both buttons and the row press map onto `toggleLabelInclude` / `toggleLabelExclude` as they are.

The e2e's hover-reveal and `toHaveCount(0)` steps encoded the deleted behaviour and are replaced by
a rail measurement taken in every state — relative to the row, since filtering moves the anchor the
menu hangs off, and behind a settle poll, since the flyout scales in and a box read mid-animation is
not a rail.

Co-authored-by: Christoph Leiter <christoph@leiter.io>

* feat(app): reveal the label filter's controls, and stop the empty state closing the menu

Phase 7 put the check and the ban on every label row at all times, which made a resting list of
labels read as two columns of buttons with a filter somewhere behind them. A row now shows its own
state and nothing else — a marked row its mark, an untouched row nothing — and hovering any row
brings both controls up, the mark you have lit and the other one unlit beside it. So the column
you read down is the filter, and the row you are pointing at is the one offering to change it.

Nothing about the rails changed. The two boxes are laid out in every state and only their ink
comes and goes: check 429-445, ban 453-469 on an included row, an excluded row, an untouched one,
hovered or not, wide and compact. The reveal is opacity, never mounting, so it cannot move the row
out from under the pointer that fired it. Hover is tracked on a plain `View` wrapping the
`MenuItem` with the presses on the buttons inside it, since `MenuItem` is itself a `Pressable`
(docs/hover.md); each control counts its own keyboard focus as revealed, so tabbing to one never
means pressing something invisible; and `pointerEvents` follows visibility so a hidden control
cannot be clicked. On native and compact there is no pointer, so both simply stand there.

Everything Phase 7 settled is kept: `Ban` at 14 in a 16pt box, `foregroundExtraMuted` for unlit
against `foregroundMuted` meaning *selected* eight pixels below, `spacing[2]` between the two with
`hitSlop` tiling the gap, the row owning `aria-checked` while the buttons stay plain named
actions, and both tooltips gated `enabledOnDesktop={isWeb}`.

---

Excluding a label that empties the sidebar closed the display menu. Logged since Phase 2, narrowed
in Phase 5 to the empty-state swap, owned by nobody.

`SidebarLabelFilterEmptyState` was a component rendered *instead of* the list, with its own
container and scroll view, and both branches were handed the same header element. Different
subtrees, so React unmounted the header on the swap — and the header holds the display menu's
trigger, so the popover went with its anchor. The empty state is now a body: both list modes
render it where their groups would go, inside the scroll they already own and below the header
they already render, and the swap can no longer reach anything above it. Cleared from that same
still-open page, the way back out is the swap in reverse and it holds too.

`ProjectModeList` was already the right shape — its "no projects yet" body sits below the header —
but its header was conditional on `unpinnedProjects.length > 0 || hasActiveHostFilter`, which is
the same bug written as a guard: keep the header when there is a filter to undo. The label filter
joins that condition rather than getting a second mechanism. Both empty states move into
`sidebar/empty-states.tsx` so the two list modes can render either without importing each other,
which also leaves the card's geometry defined once instead of once per body.

Still conditional, and left alone: a sidebar with no projects and no filter has no header at all,
so clearing the last host filter on an empty sidebar closes the menu the same way. That one is the
onboarding empty state, where a section heading over "Add a project" would be a heading over
nothing, and no menu is reachable in the state it lands in.

The e2e gets the step that would have caught this: exclude into the empty state, assert the page
you pressed on is still up, clear from it, assert the same. Verified by reverting both list files
to Phase 7 and watching it fail on "element(s) not found". Phase 7's rail assertions are kept as
they are — offsets within the row, behind a settle poll — and joined by computed-opacity reads for
the four states of the reveal.

Evidence: /tmp/labels-qa-p8/, 28 shots plus INDEX.md — the resting list, a hovered row, an
included row with the pointer away, and the empty state with the menu still open over it, at wide
and compact in both schemes.

Co-authored-by: Christoph Leiter <christoph@leiter.io>

* feat(app): make the label filter include-only

Phases 2, 6, 7 and 8 each tried to give exclusion a comfortable shape inside a 232pt menu row:
tri-state rotation, a hover-revealed control, two permanent buttons, two hover-or-lit buttons.
Every fix bought a new awkwardness, because two controls in one menu row is a lot of machinery for
a filter. The label filter is now the host filter — one tap in, one tap out — and nobody has ever
complained about that one.

A row is a plain `MenuItem` with the colour dot leading and the engine's own check trailing.
Nothing else. Which makes the alignment law trivial to satisfy, and that is the point.

Deleted rather than disabled, since 6, 7 and 8 are in the branch history if exclusion ever comes
back: both trailing controls, the hover-tracking `View`, the `opacity: 0` mounting, the focus/blur
plumbing, the `pointerEvents` gating, both tooltips and their `enabledOnDesktop={isWeb}` gates, the
lit/unlit token pair, `Ban`, and the two rails they held.

`SidebarLabelFilter` collapses onto the shape of `hostFilters` sitting three fields above it in the
same store: `labels: string[]` plus `match`, one `toggleLabelFilter`, one `clearLabelFilter`. The
exclude arm of `filterWorkspacesByLabels` goes with it, and `filter.include` / `includeLabel` /
`exclude` / `excludeLabel` leave all nine locales. Persisted version 4 with no migration — the
record shape never shipped, and `createValidatedPersistStorage` drops state that fails the current
schema before `migrate` ever runs.

Two things this leaves unused, both deleted:

- `MenuItem`'s `selected: boolean | "mixed"`, added in Phase 5 so a tri-state row could announce
  itself. The filter row was the only caller, so the union collapses back to `boolean` and the two
  `selected === true` guards that existed for `"mixed"` go back to plain truthiness.
- The `docs/menus.md` paragraphs from Phases 7 and 8 on hover-revealing a pressable trailing
  control inside a menu row. No caller does that any more, so the doc was describing a pattern the
  codebase no longer has.

Kept: `SIDEBAR_UNLABELLED_LABEL_KEY`, because `Unlabelled` is still a row and now includes like any
other; `Match any` / `Match all`, because multi-select include still means two different things.
Phase 8's empty-state fix is untouched and still holds — `hasActiveSidebarLabelFilter` is what the
header's survival condition reads, and it answers the same question against the new shape.

Evidence: /tmp/labels-qa-p9/, 12 shots plus INDEX.md — resting, two selected, and the empty state
with the menu still open over it, at wide and compact in both schemes.

Co-authored-by: Christoph Leiter <christoph@leiter.io>

* fix(app): give label-grouped rows their project icons, and label groups a tag

Grouping the sidebar by label rendered every workspace row with its placeholder
initial. The same workspace under status or project grouping showed its real
project icon.

The components were reused; the data feeding them was not. One component served
both grouping modes and threaded the icon map through correctly. Fourteen lines
above it, a second `useProjectIcons` call was gated on `groupMode === "status"`,
written when status was the only mode that put icons on rows. Phase 2 extended
the render branch to `status || label` and left the gate alone. Nothing forces a
render branch and the data it renders to agree, and these two were far enough
apart to drift in silence. Reuse buys you one implementation of the drawing; it
buys you nothing about who decides what to fetch.

So the fix is the shape, not the line. There is now one `useProjectIcons` call
for every mode, and its targets come from `buildSidebarProjection` — the same
function that produced the rows, from the same `projects` list. A mode can only
render a row for a project the projection already knows about, so the map cannot
disagree with the screen. The gate is gone rather than extended: extending it
would leave the identical trap for the next grouping mode. `useProjectIcons`
dedupes by `serverId:projectId` and caches through `useQueries`, so the gate was
a premature optimization that bought a bug.

Pinned by `sidebar-projection.test.ts`: for each of the three modes, every
project view key the projection will render — pinned rows, grouped rows, project
headers and the rows under them — has an icon target. Red on the old gate:
restoring `groupMode === "status" ? … : []` in its new home fails project and
label grouping with `['project', 'other-project']` uncovered, and status passes,
which is exactly the bug's signature.

The sibling sweep over everything `SidebarStatusModeWrapper` is handed found one
more of the same shape, correct today: the projection chose its shortcut sections
with `status || label`, so a fourth mode would have silently walked the project
rows. Both the groups and the sections now come from one exhaustive `switch` on
the mode, which makes the next mode a compile error rather than an empty list.
`workspaceEntriesByKey` is already written the safe way round (`!== "project"`),
and the rest — host badges, pinning support, the empty-filter state — never read
the mode at all. Two names moved with the shape: `SidebarStatusModeWrapper` is
`SidebarGroupedModeList`, and its branch is `groupMode !== "project"`, so a new
mode lands in the grouped list instead of the project list's `else`.

Also: label group headers draw lucide `Tag` instead of `CircleDot`, in the
label's colour, muted for `Unlabelled` — the glyph the Labels menu and the
workspace menu already use for this feature. It keeps `StatusGroupIcon`'s 14
with no optical nudge, and that is measured rather than assumed: in the header's
leading slot both ink a 14x14 box at the same offset, and the tag lays down 76
ink pixels to the circle's 84, so reaching the corners of its box costs it no
weight beside the status glyphs.

QA: /tmp/labels-qa-p10/ — wide and compact, light and dark, label grouping and
the same workspaces under status grouping, plus the hover chevron swap, the
glyph zooms, and a before/after pair taken with the fix reverted to the bug's
shape.

Co-authored-by: Christoph Leiter <christoph@leiter.io>

* fix(app): align label chip typography

Co-authored-by: Christoph Leiter <christoph@leiter.io>

* docs: format architecture module table

Co-authored-by: Christoph Leiter <christoph@leiter.io>

* fix(app): narrow workspace labels workflow

Co-authored-by: Christoph Leiter <christoph@leiter.io>

* fix(app): mirror bottom sheet test exports

Co-authored-by: Christoph Leiter <christoph@leiter.io>

* test(desktop): assert project browse by destination

Co-authored-by: Christoph Leiter <christoph@leiter.io>

* fix(app): retain empty projects without label filters

Co-authored-by: Christoph Leiter <christoph@leiter.io>

* fix(app): preserve status grouping test contract

Co-authored-by: Christoph Leiter <christoph@leiter.io>

* fix(app): retain filters after label load failures

Co-authored-by: Christoph Leiter <christoph@leiter.io>

---------

Co-authored-by: Christoph Leiter <christoph@leiter.io>
2026-08-18 12:49:23 +00:00

33 KiB

Architecture

Paseo is a client-server system for monitoring and controlling local AI coding agents. The daemon runs on your machine, manages agent processes, and streams their output in real time over WebSocket. Clients (mobile app, CLI, desktop app) connect to the daemon to observe and interact with agents.

Your code never leaves your machine. Paseo is local-first.

System overview

┌─────────────┐    ┌─────────────┐    ┌─────────────┐
│  Mobile App  │    │     CLI     │    │ Desktop App │
│   (Expo)     │    │ (Commander) │    │ (Electron)  │
└──────┬───────┘    └──────┬──────┘    └──────┬──────┘
       │                   │                  │
       │    WebSocket      │    WebSocket     │    Managed subprocess
       │    (direct or     │    (direct)      │    + WebSocket
       │     via relay)    │                  │
       └───────────┬───────┴──────────────────┘
                   │
            ┌──────▼──────┐
            │   Daemon    │
            │  (Node.js)  │
            └──────┬──────┘
                   │
      ┌────────────┼────────────┬────────────┬────────────┐
      │            │            │            │            │
┌─────▼─────┐ ┌───▼────┐ ┌──────▼─────┐ ┌────▼─────┐ ┌────▼────┐
│  Claude   │ │ Codex  │ │  Copilot   │ │ OpenCode │ │   Pi    │
│  Agent    │ │ Agent  │ │   Agent    │ │  Agent   │ │ Agent   │
│  SDK      │ │ Server │ │    ACP     │ │          │ │         │
└───────────┘ └────────┘ └────────────┘ └──────────┘ └─────────┘

Components at a glance

  • Daemon: Local server that spawns and manages agent processes and exposes the WebSocket API.
  • App: Cross-platform Expo client for iOS, Android, web, and the shared UI used by desktop.
  • CLI: Terminal interface for agent workflows that can also start and manage the daemon.
  • Desktop app: Electron wrapper around the web app that bundles and auto-manages its own daemon.
  • Relay: Optional encrypted bridge for remote access without opening ports directly.

Packages

packages/server — The daemon

The heart of Paseo. A Node.js process that:

  • Listens for WebSocket connections from clients
  • Manages agent lifecycle (create, run, stop, resume, archive)
  • Streams agent output in real time via a timeline model
  • Provides agent-to-agent tools through a transport-neutral tool catalog, with MCP as one adapter
  • Optionally connects outbound to a relay for remote access
  • Optionally serves the browser web client from the same HTTP server (self-hosting guide: public-docs/web-ui.md)

All paths are under packages/server/src/.

Project identity is daemon-global rather than session-owned. After registry bootstrap, the daemon's project Git observer keeps one non-recursive watch on each lexically equivalent active project root and listens only for the root .git entry, with a slow rescan as a missed-event fallback. It runs for empty projects and without connected clients, then fans metadata changes through the WebSocket server to capability-aware sessions. It deliberately does not use the broad recursive working-tree watcher or the per-session Git observer: those are checkout/status mechanisms and intentionally do not retain non-Git directories.

Key modules:

Module Responsibility
server/bootstrap.ts Daemon initialization: HTTP server, WS server, agent manager, storage, relay
server/websocket-server.ts WebSocket connection management, hello handshake, binary frame routing
server/session.ts Per-client session state, timeline subscriptions, terminal operations
server/directory-sync/ Daemon-global latest-state sequences for projects, workspaces, and agents
server/workspace-labels/ Host-local label catalog, assignment mutations, and explicit subscriptions
server/agent/agent-manager.ts Agent lifecycle state machine, timeline tracking, subscriber management
server/agent/agent-storage.ts File-backed JSON persistence at $PASEO_HOME/agents/
server/agent/tools/ Transport-neutral catalog for workspaces, agents, permissions, and automation
server/agent/mcp-server.ts Thin MCP adapter that registers the Paseo tool catalog with the MCP SDK
server/agent/providers/ Provider adapters (see "Agent providers" below)
server/orchestration-skills/ Bundled catalog, host selection, convergence, and skill-directory transactions
server/relay-transport.ts Outbound relay connection with E2E encryption
server/schedule/ Cron-based scheduled agents

packages/protocol — Wire schemas and shared protocol types

The source of truth for WebSocket messages, binary frame codecs, endpoint parsing, agent timeline types, provider config schemas, and other values shared by daemon and clients. Server, app, CLI, and @getpaseo/client all depend on this package; it does not depend on the server.

packages/client — Daemon client library and SDK facade

Owns the low-level daemon WebSocket driver plus the higher-level PaseoClient facade. App and CLI may import the low-level driver from @getpaseo/client/internal/daemon-client during migration, while new SDK-shaped code imports from @getpaseo/client.

PaseoApi is the capability-only boundary over workspaces, agents, providers, and config. PaseoClient adds connection lifecycle. App plugin surfaces borrow an API over their selected host's client; plugin subprocesses use the same facade over a host-owned IPC transport.

packages/app — Mobile + web client (Expo)

Cross-platform React Native app that connects to one or more daemons.

  • Expo Router navigation (/h/[serverId]/workspace/[workspaceId], /h/[serverId]/agent/[agentId], etc.). The workspaceId URL segment is an opaque workspace id, not a directly meaningful filesystem path.
  • HostRuntimeController manages saved host connections, reconnection, and per-host runtime state
  • runtime/replica-cache keeps the complete project, workspace, and active-agent directory plus one short focused timeline tail in AsyncStorage. It restores before navigation becomes ready and leaves remote hydration flags false.
  • runtime/directory-sync owns directory reconciliation. On reconnect it passes the persisted per-entity cursor through project.list, fetch_workspaces, and fetch_agents; the daemon returns each entity's latest projection when its sequence is newer, plus tombstones.
  • workspace-labels owns one sequenced catalog replica per connected host, the deterministic cross-host projection that surfaces spanning hosts use (the filter page, the manager), and the per-host resolution a workspace row's chips use. Two hosts may give one name different colors, so a row resolves against its own host's catalog and a merged answer would be wrong there. Catalogs never synchronize between hosts; assignment creates a missing definition only on the target host. On the daemon, catalog and assignment rewrites share a journaled commit boundary. Startup recovery completes that commit before workspace or catalog publication.
  • SessionContext wraps the daemon client for the active session
  • Composer UI and submit/draft behavior live in packages/app/src/composer/; screens and panels should integrate it from there instead of dropping composer internals into components/, hooks/, or screens/workspace/
  • Timeline reducers in timeline/session-stream-reducers.ts handle compaction, gap detection, sequence-based deduplication
  • Timeline sync correctness is documented in docs/timeline-sync.md: live streams are for immediacy, fetch_agent_timeline_request is authoritative, and catch-up is paged but complete.
  • Voice features: dictation (STT) and voice agent (realtime)

The replica cache paints stale data immediately while the host connects. Directory cursors are reconciliation checkpoints; cached entities remain non-authoritative until the daemon answers. Pending permission requests are not restored from it. AsyncStorage is not encrypted, so the cached timeline tail may contain source code, prompts, and tool output; encrypted-at-rest storage is a separate product/security decision. Its serialized payload has a 32 MiB byte budget and evicts whole host snapshots in least-recently-written order; a single oversized host is omitted rather than partially restored. Browser and Electron builds store it in IndexedDB. Native builds use AsyncStorage, and Android reserves 64 MiB for that database.

The three directory entity types have independent monotonic sequences and share one daemon generation. The daemon retains only the latest projection per entity and bounded tombstones, not an event log. A missing, expired, or previous-generation cursor receives a full snapshot. Projects are independent records; a project with no workspaces does not need a workspace placeholder.

Workspace label definitions use a separate, explicitly subscribed sequence. The list request both fetches and grants live updates for that session. A current cursor receives an empty correlated catch-up response when nothing changed; idle sessions and unsubscribed sessions receive no label traffic. Workspace assignments stay on the workspace directory sequence.

packages/cli — Command-line client

Commander.js CLI with Docker-style commands. Common agent operations are also exposed at the top level (e.g. paseo ls, paseo run).

  • paseo agent ls/run/import/attach/logs/stop/delete/send/inspect/wait/archive/reload/update/mode
  • paseo daemon start/stop/restart/status/pair/set-password
  • paseo terminal ls/create/capture/send-keys/kill
  • paseo script ls/start/stop
  • paseo schedule create/ls/inspect/update/pause/resume/run-once/logs/delete
  • paseo heartbeat create/update/delete
  • paseo project create/ls/rename/delete
  • paseo workspace create/ls/rename/archive
  • paseo permit allow/deny/ls
  • paseo provider ls/models
  • hidden legacy paseo worktree create/ls/archive compatibility alias
  • paseo speech …

Communicates with the daemon via the same WebSocket protocol as the app.

packages/relay — Relay transport and E2E encryption

Enables remote access when the daemon is behind a firewall.

  • Curve25519 ECDH key exchange + XSalsa20-Poly1305 (NaCl box) encryption
  • The relay is zero-knowledge — it routes encrypted bytes and cannot read content
  • Client and daemon channels with identical API (createClientChannel, createDaemonChannel)
  • Pairing via QR code transfers the daemon's public key to the client
  • New homes keep relay disabled until pairing consent. DaemonConfigStore persists the desired state, while the relay runtime starts or stops the outbound transport live; pairing reads that current state instead of a startup snapshot.
  • Optional E2EE capability negotiation preserves application frame kind: text plaintext uses base64 ciphertext text frames, while binary plaintext uses raw ciphertext binary frames; mixed-version peers remain base64-only
  • Self-hosted relays opt into TLS with daemon.relay.useTls or PASEO_RELAY_USE_TLS=true; the public (client-facing) TLS setting can be overridden independently via daemon.relay.publicUseTls or PASEO_RELAY_PUBLIC_USE_TLS

The production relay server lives in getpaseo/paseo-relay. It is a distributed Elixir service. The Cloudflare relay implementation in this monorepo is retained as legacy code and is not deployed.

See SECURITY.md for the full threat model.

Paseo Hub

The optional Hub relationship is daemon-outbound and does not use the relay. Its connection, authorization, ownership, persistence, and lifecycle contract is documented in hub.md.

packages/desktop — Desktop app (Electron)

Electron wrapper for macOS, Linux, and Windows.

  • Can spawn the daemon as a managed subprocess
  • Native file access for workspace integration
  • Same WebSocket client as mobile app

The desktop does not manage agent skills. It retains one compatibility reader for the old skill-selection.json, imports that preference into its managed local daemon, then deletes the old file after the daemon confirms persistence.

Multi-window (hybrid land-on model). createWindow() in main.ts is reusable: ⌘⇧N/File→New Window, relaunching the app (second-instance), and the sidebar "Open in new window" action each open a fresh BrowserWindow. Every window shows the full sidebar — there is no per-window project ownership or filtering. "Land on a project" is delivered by a per-webContents PendingOpenProjectStore: each window pulls its own pending project path on mount (paseo:get-pending-open-project) and runs the normal open-project flow, identical to a CLI paseo <path> launch.

Window-state v1 limitation: only the first window of a session restores and persists saved geometry (size/position/maximized). Windows opened via ⌘⇧N / second-instance / "Open in new window" open at the default size, OS-cascaded, and do not persist — this avoids every window stacking on the same restored bounds and fighting over the single window-state store. Lifting this needs per-window state keys.

In-app browser profile. Every browser guest uses one stable persistent Electron session, so cookies, authentication, cache, and site storage are shared across tabs, workspaces, and desktop windows and survive tab or app closure. Browser identity is independent of that storage partition: after every did-attach, the renderer explicitly registers its browser id, workspace id, and current guest WebContents id, and main accepts the registration only when that guest belongs to the calling renderer and the shared profile. Registration is intentionally repeated because reparenting a retained <webview> can replace its guest without replacing the DOM element. Settings > General > Clear browser data is the sole profile-deletion path; it clears the shared session and reloads live guests without deleting saved tabs or URLs.

In-app browser window opens. Ordinary link opens, including Shift-clicked links, become Paseo workspace tabs. Script-created opens with popup features or a named window target and POST-backed opens remain secured Electron child windows in the shared browser profile, preserving window.opener, postMessage, named-window reuse, request bodies, and window.close() for OAuth, payment, and similar popup protocols. Unsupported URL schemes are denied before either path.

In-app browser ownership. Each registered guest records its owning host window. The active browser is keyed by (host window, workspace), and application-menu Reload / Force Reload resolve only within the window Electron supplies to the menu callback. A non-null active update must name a browser owned by that host; a null update clears only that host/workspace. Browser automation continues to target explicit browser ids returned by browser_new_tab or browser_list_tabs.

Browser keyboard boundary. Guest pages receive renderer-published shortcuts first. Cmd/Ctrl+L and Cmd/Ctrl+R are explicit guest-shell reservations; ordinary Paseo shortcuts run only after the page declines them. The sandboxed guest preload runs in every frame so focused iframes use the same boundary, while Node integration remains disabled. Human guest input disables Electron's menu fallback for plain keys. Agent-generated keys use guest sendInputEvent with skipIfUnhandled, so an unhandled Enter stops at the guest instead of reaching the host composer. Main selects the preload; it exposes no APIs to guest pages.

Human key -> guest WebContents
  |-- Cmd/Ctrl+T/L/R ----------> reserved browser-shell action
  `-- page keydown
        |-- page prevents ------> page owns it
        `-- published shortcut -> guest preload -> IPC(browserId) -> Paseo resolver

Agent browser_keypress -> guest sendInputEvent(skipIfUnhandled)
  |-- guest handles ------------> page owns it
  `-- guest does not handle ----> stop; never redispatch to the host window

packages/website — Marketing site

TanStack Router + Cloudflare Workers. Serves paseo.sh.

WebSocket protocol

All clients speak the same WebSocket protocol over a single connection that mixes JSON text frames and a small binary framing for terminal streams. Schemas live in packages/protocol/src/messages.ts.

Handshake:

Client → Server:  WSHelloMessage {
                    type: "hello",
                    clientId,
                    clientType: "mobile" | "browser" | "cli" | "mcp",
                    protocolVersion,
                    appVersion?,
                    capabilities?: { voice?, pushNotifications?, ... },
                  }
Server → Client:  status message with payload { status: "server_info",
                    serverId, hostname, version, capabilities?, features }

There is no dedicated welcome message; the server emits a status session message after accepting the hello, then begins streaming. The session stores client capabilities from the hello and rehydrates them on reconnect, so the wire boundary can ask one question: session.supports(...).

Top-level WS envelopes are hello, recording_state, ping/pong, and session (which wraps the rich union of session messages).

Client liveness checks use the top-level JSON ping/pong envelope, not a session RPC or RFC6455 control ping. Current clients ping every 10 seconds, beginning one interval after connecting. The first ping claims an application-ownership lease for that physical socket, all later inbound activity renews it, and the daemon forcibly terminates the socket if the lease expires. A legacy or raw socket that never sends an application ping never enters this lease and is not closed for omitting one. Session RPC timeouts are operation failures and must not be treated as proof that the socket is dead.

Every physical send path enforces an 8 MiB outbound high-water mark, including JSON broadcasts, binary terminal frames, and the encrypted relay adapter's asynchronous queue. This sits above the terminal stream's 4 MiB soft backpressure threshold, leaving room for snapshot catch-up before the hard cutoff. JSON is serialized once per broadcast after sockets already at the limit are removed, then its exact byte length is checked for every remaining socket. A frame that would cross the limit is not sent; that physical socket is forcibly terminated without disturbing other sockets attached to the same logical session. Multiple tabs and simultaneous direct and relay paths may legitimately share a client id.

Client session RPC waits default to 60s so slow relay or mobile networks do not turn a live but delayed daemon response into a false operation failure. Keep connect timeouts, app-level grace windows, explicit diagnostic latency probes, liveness ping timers, and genuinely long-running RPCs separate from this default.

New session RPCs use dotted names with .request and .response suffixes, such as checkout.forge.set_auto_merge.request and checkout.forge.set_auto_merge.response. See rpc-namespacing.md for the convention and migration rules for older flat RPC names.

Notable session message types:

  • agent_update — Agent state changed (status, title, labels)
  • agent_stream — New timeline event from a running agent
  • 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

Agent snapshots optionally carry the daemon-owned active turn identity, and turn lifecycle stream events optionally carry the same turnId. New clients use these fields when present and normalize an old daemon's status once at the directory boundary rather than maintaining a second activity model.

  • Terminal subscribe/input/capture commands
  • Voice/dictation streaming events (dictation_stream_*, assistant_chunk, audio_output, transcription_result)
  • Request/response pairs for fetch, list, create, etc., correlated by requestId; failures use rpc_error

directory_suggestions_request is one daemon-owned filesystem search capability. The daemon configures the same searchDirectoryEntries engine with a root, output format, path-query policy, entry-kind filters, match mode, blank-query behavior, and hidden-directory traversal policy. A request without cwd searches the host home for absolute project paths; a request with cwd searches that workspace and returns relative entries. Clients may prepend their small host-scoped recent-project list for bare queries, but must not parse filesystem query syntax or re-filter a correlated daemon response. The legacy directories response field remains a projection of the typed entries list.

Binary frames (terminal stream protocol):

Terminal I/O is sent as binary WebSocket frames decoded by decodeTerminalStreamFrame in shared/binary-frames/terminal.ts. The layout is:

  • 1-byte opcode: Output (0x01), Input (0x02), Resize (0x03), Snapshot (0x04)
  • 1-byte slot: terminal slot id
  • variable payload: bytes for output/input, JSON-encoded { rows, cols } for resize, terminal snapshot for snapshot

Terminal PTY size is last-interacting-client-wins. A client claims the PTY size only when its terminal viewport genuinely changes size or the user focuses/taps the terminal. Passive rendering work — attaching, restoring visibility, font settling, renderer refits, or just looking at a visible terminal — must not send a resize frame. The server does not broadcast resize ownership; the resized PTY redraws through normal output, and every attached client renders that output in its own local viewport.

There is also a separate file-transfer binary frame format in the same directory, used for download/upload streams. File downloads keep the existing FileBegin/FileChunk/FileEnd framing and stream 256 KiB chunks from one stable file handle. Each transfer awaits completion of its own physical WebSocket send before reading the next chunk; it is scoped to the requesting physical socket and does not queue unrelated messages or transfers.

Compatibility rules

  • WebSocket schemas are append-only. Add fields, do not remove fields, and never make optional fields required.
  • New wire enum values must be gated at serialization with session.supports(CLIENT_CAPS.someCapability).
  • Session stores client capabilities from the hello handshake and rehydrates them on reconnect, so the wire boundary can ask one question: session.supports(...).

Example: adding a new enum value

// 1. Add CLIENT_CAPS.newThing = "new_thing"
// 2. Let new clients advertise it in WS hello
// 3. Keep the shared producer schema strict
// 4. Gate the new emitted value: session.supports(CLIENT_CAPS.newThing) ? "new_value" : "old_value"

Agent lifecycle

The lifecycle states are defined in shared/agent-lifecycle.ts:

initializing → idle ⇄ running
        ↓       ↓        ↓
              error
                ↓
              closed
  • initializing — provider session is being 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 sequence allocation is append-only with epochs (each run starts a new epoch). The one permitted in-place enrichment adds a provider message id to the manager-owned row for an accepted prompt; it preserves the row's sequence, content, and timestamp. Storage uses sequence numbers for client-side dedup; the default fetch page is 200 items.
  • Timeline row timestamp values are canonical daemon-owned timestamps. Providers may supply original replay timestamps, but clients must not guess timestamp trust or hide time UI based on local clock heuristics.
  • Events stream to connected clients in real time; correctness is backed by authoritative timeline fetches and paged-to-completion catch-up.
  • Agent state persists to $PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json (timeline rows live alongside the record). That storage path is derived from cwd, not from workspace id.

Right-sidebar boundary: directory-backed vs workspace-owned

Two workspaces can share the same cwd (e.g. a directory workspace and a local_checkout workspace on the same folder, or several workspaces opened against one checkout). Model B keeps these distinct: they share everything the directory determines, but nothing the workspace owns. The right-sidebar surfaces split cleanly along this line, and the split is enforced purely by what each piece of state is keyed by.

Directory-backed (shared by same-cwd workspaces) — keyed by (serverId, cwd), never by workspaceId:

Surface Key Source
Git status checkoutStatusQueryKey(serverId, cwd) packages/app/src/git/query-keys.ts
Git diff checkoutDiffQueryKey(serverId, cwd, mode, baseRef, ws) packages/app/src/git/query-keys.ts
Forge change request checkoutPrStatusQueryKey(serverId, cwd) packages/app/src/git/query-keys.ts
Change request timeline prPaneTimelineQueryKey({ serverId, cwd, prNumber }) packages/app/src/git/pull-request-panel/query-keys.ts
File preview content ["workspaceFile", serverId, cwd, path] packages/app/src/components/file-pane.tsx
File explorer listings fetched via listDirectory(workspaceRoot, path) packages/app/src/hooks/use-file-explorer-actions.ts

Workspace-owned (independent per workspace) — keyed by workspaceId (falling back to cwd only when no workspaceId exists):

State Key builder / store Source
Review draft comments buildReviewDraftKey / buildReviewDraftScopeKey packages/app/src/review/store.ts
Diff mode override review-draft scope key (in-memory) packages/app/src/review/state.ts
Composer attachments buildWorkspaceAttachmentScopeKey packages/app/src/attachments/workspace-attachments-store.ts
File explorer nav/open state fileExplorer map keyed workspace:{workspaceId} packages/app/src/hooks/use-file-explorer-actions.ts
File explorer expanded paths expandedPathsByWorkspace[workspaceStateKey] packages/app/src/stores/panel-store/state.ts

diff-pane.tsx is the canonical wiring site: it passes { serverId, cwd } to the git queries and { serverId, workspaceId, cwd } to the draft/override/attachment scope keys.

Do not "fix" the sharing away. Re-keying a directory-backed query by workspaceId makes same-cwd workspaces diverge (two windows onto the same git tree showing different diffs). Re-keying owned state (drafts, expanded paths) by cwd makes them leak between distinct workspaces on the same folder. The workspaceId-keyed builders carry a // workspaceId is opaque; do not parse this key back into a path. comment — the opaque-id fallback to cwd exists only for old payloads without a workspaceId, not as a content-sharing mechanism.

One deliberate non-violation: AgentFileExplorerState.directories/files cache directory listings inside the workspaceId-keyed explorer map. Same-cwd workspaces therefore keep duplicate caches, but they can never diverge — both fetch the identical directory via listDirectory(workspaceRoot, …). This is duplication, not leakage, and is left as-is.

Agent providers

Each provider implements the AgentClient interface in agent/agent-sdk-types.ts. Provider implementations live in agent/providers/.

The built-in, user-facing providers are Claude Code, Codex, Copilot, OpenCode, Pi, and OMP. Additional adapters exist in the same directory for ACP-compatible agents and internal use:

Provider Wraps Session format
Claude (claude/) Anthropic Agent SDK ~/.claude/projects/{cwd}/{session-id}.jsonl
Codex Codex AppServer (codex-app-server) ~/.codex/sessions/{date}/rollout-{ts}-{id}.jsonl
Copilot GitHub Copilot via ACP Provider-managed
OpenCode OpenCode server / CLI Provider-managed
Cursor ACP wrapper (acp-agent) Provider-managed
Generic ACP ACP wrapper Provider-managed
Pi Local Pi RPC process Provider-managed
Mock load test In-process fake In-memory

All providers:

  • Handle their own authentication (Paseo does not manage API keys)
  • Support session resume via persistence handles
  • Map tool calls to a normalized ToolCallDetail type
  • Expose provider-specific modes (plan, default, full-access)

Providers that can accept native tool definitions should set supportsNativePaseoTools and read launchContext.paseoTools. The daemon then passes the shared Paseo tool catalog directly and removes the internal Paseo MCP server from that provider launch config. Providers that only support MCP continue to receive the same tools through the MCP fallback at /mcp/agents.

Data flow: running an agent

  1. Client sends CreateAgentRequestMessage with config (prompt, cwd, provider, model, mode)
  2. Session routes to AgentManager.create()
  3. AgentManager creates a ManagedAgent, initializes provider session
  4. Provider runs the agent → emits AgentStreamEvent items
  5. Events append to the agent timeline, broadcast to all subscribed clients
  6. Tool calls are normalized to ToolCallDetail (shell, read, edit, write, search, etc.)
  7. Permission requests flow: agent → server → client → user decision → server → agent

Storage

$PASEO_HOME defaults to ~/.paseo. The most important files:

$PASEO_HOME/
├── agents/{cwd-with-dashes}/{agent-id}.json   # Agent record + persisted timeline rows
├── projects/projects.json                      # Project registry
├── projects/workspaces.json                    # Workspace registry
├── projects/icons/                             # Custom project icon images
├── schedules/                                  # Scheduled-agent definitions and runs
├── config.json                                 # Daemon config (mutable)
├── daemon-keypair.json                         # Daemon identity for relay/E2EE
├── push-tokens.json                            # Mobile push tokens
├── paseo.sock / paseo.pid                      # Local IPC socket and pidfile
└── daemon.log                                  # Daemon trace logs (rotated)

Deployment models

  1. Local daemon (default): paseo daemon start on 127.0.0.1:6767
  2. Managed desktop: Electron app spawns daemon as subprocess, and stops it again on quit so that "restart the app" is a complete reset. Settings > Host > "Keep daemon running after quit" opts out. Only a daemon the desktop started is stopped — a daemon you started yourself with paseo daemon start is left alone (paseo.pid records desktopManaged).
  3. Remote + relay: Daemon behind firewall, relay bridges with E2E encryption