docs/mac-relay.md

Mac App & Desktop-Helper Relay Architecture

Source Specs: mac-swift-prd.md, mac-app-beautify-plan.md, mac-ux-9-prd.md, docs/22-remote-engines.md, the engine plugin ecosystem notes

The native macOS app (apps/mac, Swift/SwiftUI) communicates with a local or attached remote engine (apps/desktop-helper, Node/TypeScript) over an authenticated HTTP + SSE relay. The relay supervises a single engine host process and translates Host Remote API calls without fabricating synthetic state.

---

1. C1 Zero-Fallback Invariant (Hard Rule)

---

2. Key Components

PathResponsibility
Engine host module (apps/desktop-helper/src/)Spawns and supervises the engine with --profile <profile> --patch ... --no-open --host 127.0.0.1 --port 0, parses the launch URL/token, generates the PICASSO_PLUGINS patch layer and mcp-servers.json patch layer, and manages child process lifecycle (parent-watch.ts).
Engine remote client (apps/desktop-helper/src/)Typed client for the engine's Host Remote API (144+ endpoints including sessionController, workspaceController, settingsController, jobController, terminalController, and Picasso's custom Host Remote plugin endpoints).
apps/desktop-helper/src/relay.tsnode:http + SSE surface consumed by apps/mac: /health, /threads/*, /interactions, /providers/*, /mcp/servers, /schedules/*, /desktop/settings*, /desktop/memory*, /desktop/cloud-sessions*, /extensions/*, /pair/*, /shutdown. Authenticates three scopes: owner (PICASSO_HELPER_TOKEN), companion (paired pct_ tokens) and local-attach (the discovery-file token).
apps/desktop-helper/src/main.tsHelper entry: boots the engine, binds the relay, writes and removes the discovery file, emits the ready event.
apps/mac/Picasso/Services/LocalHelperProcess.swift, EngineHome.swift, ContainerDataMigration.swiftLaunch environment (real homes, PICASSO_HELPER_PORT=0, PICASSO_PARENT_PID, PICASSO_MEMORY_FILE), crash relaunch, and the one-time container migration.
apps/desktop-helper/scripts/bundle.mjsStages Node, the pinned engine package (0.2.2-rc.1), profile-seed/, and all packages/picasso-* plugins into apps/mac/Picasso/Resources/PicassoEngine for standalone .app signing and distribution.
apps/mac/Picasso & apps/mac/PicassoTestsNative SwiftUI workspace, Thread view (ThreadEventStore), Inspector, Extensions (Skills & MCP catalog state + token cost), Memory view (inline vs. on-demand badges), and Settings › Engines (bundled + SSH/Tailscale remote fleet).

---

2a. Process model, homes and port

2b. Auth scopes

ScopeCredentialReach
ownerAuthorization: Bearer $PICASSO_HELPER_TOKEN (per launch, env only)Every route, including /desktop/*, /pair/*, research and POST /shutdown.
companionPaired-device pct_ token (header, or ?token= on GET …/events only)The companionAllows allowlist in pairing.ts; never /desktop/* (403). Tokens expire 30 days after pairing.
local-attachThe discovery-file token, header only, from a loopback peer onlyLOCAL_ATTACH_ROUTES in @picasso/shared-home/attach-scope: GET /health, GET /threads, GET /threads/:id/events, POST /threads/:id/prompt, POST /threads/:id/cancel, GET /threads/:id/commands, GET /interactions, POST /interactions/:id, POST /interactions/:id/hold. Anything else is 403 scope_denied; it cannot create threads (H-S4) and cannot grant allow-always.

2c. Discovery file (X7)

When the helper runs as the app's loopback engine (PICASSO_PARENT_PID set, a launch bearer present) and PICASSO_HELPER_ATTACH is not 0 (Settings › Engine › "Allow terminal attach"), main.ts mints a separate 64-hex local-attach token and, after every listener is up and just before ready, writes ~/.picasso/run/mac-helper.json with mode 0600 in a 0700 directory (@picasso/shared-home/discovery writeHelperDiscovery):

{ "version": 1, "kind": "picasso-mac-helper", "uid": 501, "pid": 1234, "parentPid": 1200,
  "startedAt": "2026-10-28T12:00:00.000Z", "url": "http://127.0.0.1:53114",
  "token": "<64 hex, not PICASSO_HELPER_TOKEN>", "tokenScope": "local-attach",
  "helperVersion": "1.0.0", "dshVersion": "0.2.2-rc.1", "dshHome": "/Users/<you>/.picasso/dsh-home" }

The writer refuses a non-loopback URL or the owner token. A new launch atomically replaces the file; shutdown and process exit remove it. Readers (picasso attach --mac) refuse a symlink, a file with group/other permission bits or a directory that is not 0700, another uid, an unsupported version, a non-loopback URL, or a helper whose GET /health version differs (DiscoveryRefusedError codes discovery/*). If the file cannot be written, the app keeps serving and only terminal attach reports that it is unavailable.

---

3. Plugin Mounting & Surface Parity

  1. `PICASSO_PLUGINS` (the engine host module): Every Host booted by the relay mounts picasso-session-lifecycle, picasso-memory, picasso-schedule-pause, picasso-provider-models, picasso-auth-bridge, picasso-checkpoints, picasso-browser, picasso-commands, picasso-auto-approve, picasso-lifecycle-hooks, picasso-repo-index, picasso-trackers, picasso-mail, picasso-model-router, picasso-lean-context, picasso-progress-reports and picasso-librarian. The relay does not load dsh/picasso-ecosystem.cordis.patch.yml, dsh/picasso-sdk.patch.yml or dsh/picasso-ecosystem.community.patch.yml (main.ts accepts PICASSO_DSH_PATCHES, but nothing sets it). The Mac profile is the engine's base and web-app bundles (profile-seed/package.json), which mount most of the official layer themselves, plus rows the relay generates in the engine host module (plugin-market, agent-instructions, per-server mcp-*, worktree, tool-worktree, speech-to-text, voice-input UI) and the persona presets. Official plugins the CLI mounts that the Mac does not yet carry (agent-team pair, tool-ralph, tool-session-query, auto-review, terminal trio) are tracked separately until the Mac carries them.
  2. Mac-only transport plugins: the engine's host web server, API gateway, API controllers and schedule plugin live on the Mac relay profile (via the web-app bundle) because they depend on the HTTP Host Remote API (sessionController), whereas the CLI/TUI uses stdio JSON-RPC (@picasso/sdk-server). The schedule tool reaches the Mac through the web-app bundle's agent presets (standard, ptc, cordis), not as a separate mount. The CLI mounts both schedule entries with disabled: true.
  3. Throwaway `DSH_HOME` in dev/tests: Automated tests and dev helpers must isolate DSH_HOME to a temporary directory (unless PICASSO_HELPER_DSH_HOME=operator is explicitly set) so test sessions never pollute the operator's ~/.picasso/dsh-home/sessions sidebar.
  4. Typography & Dense UI: All SwiftUI views in apps/mac must strictly follow /Users/gurbakshchahal/fonts/README.md and /Users/gurbakshchahal/.config/agent-policy/reference/ui-typography-density.md (licensed fonts only; dense 13–14px body, 12px meta floor, 34px rows, 32px controls, no bold in dashboards).

TLS readiness, pairing, and shutdown

The helper owns one Relay and one engine connection. PICASSO_HELPER_PORT remains the primary listener port. When PICASSO_HELPER_TLS_CERT and PICASSO_HELPER_TLS_KEY are configured, the primary listener uses HTTPS and a separate ephemeral HTTP listener binds only to literal 127.0.0.1. Both share authentication, routing, pending interactions, and research runtime. A certificate selected while the Mac Remote toggle is off never enables a network bind.

The ready event reports port and localBaseURL for the local HTTP client, with separate network: {scheme, port} metadata for the primary listener. The Mac accepts only a literal-loopback HTTP readiness URL with a valid explicit port, no credentials, query, or fragment, and a root path. Older events with only port retain compatibility. Remote engine connections continue using their configured trusted URL.

Set PICASSO_HELPER_ADVERTISED_ORIGIN to the externally reachable HTTPS origin (for example https://engine.example:4001). Pairing requires a LAN-facing TLS listener, matching primary port, and a hostname/IP covered by its certificate SAN. No origin is inferred from local interfaces. /pair/state exposes advertisedOrigin and pairingOriginError; /pair/codes returns its complete authoritative HTTPS /pair#c=... URL or a deterministic pairing_origin_not_configured / pairing_origin_invalid configuration error before minting a code. Normal browser and URLSession certificate trust validation remains enabled.

Relay.close() closes all owned listeners, ends active responses (including SSE and attached streams), cancels terminal attachments, stops the browser pump/backoff, and closes research routes once. Startup bind failures reject and enter the same bounded helper shutdown path as SIGTERM, SIGINT, engine exit, and parent disappearance. That path closes the remote connection, stops research, and terminates the engine process group, with a one-second forced cleanup bound. Ready is emitted only after all required sockets are listening.

The engine never outlives its relay, even when the relay is killed with SIGKILL (no handler runs then). startDshHost loads ENGINE_PARENT_WATCH into the engine with NODE_OPTIONS --import, active only in the process whose parent is the launching relay (PICASSO_ENGINE_PARENT_PID, removed from the engine's environment so tools never see it). The relay holds the write end of the engine's stdin and never writes; on stdin EOF, or when the engine's parent pid changes, the engine SIGTERMs its own process group and SIGKILLs it 3 s later. A SIGTERM/SIGINT that arrives while the engine is still booting stops the half-started engine group too (onSpawn). Proof: apps/desktop-helper/test/engine-orphan.test.ts.

A subagent inherits its parent's approval policy, so its blocked call asks the person. The relay labels such an approval from the child's session/list row (origin: 'subagent', parentSessionId, projections.values.subagent.label): requester {kind, sessionId, parentSessionId, name?, label}, requesterLabel ("WP-3 subagent") and parentSessionId ride on the approval event, the replay and the interactions-snapshot. A child the relay has not listed yet is looked up once and its approval is announced again with the label. The Mac shows it in the parent's dock as "WP-3 subagent wants to run bash"; the TUI gets the same fields from @picasso/sdk-server (requester.js). Proofs: apps/desktop-helper/test/subagent-approval-e2e.test.ts, packages/core/test/subagent-approval-runtime.test.ts.

Approval decisions and question answer/decline requests keep their pending interaction until the engine acknowledges them. A per-interaction in-flight guard returns 409 interaction_in_flight for duplicate clicks. Returned/transport failures remain retryable without grants, durable rules, or success events. Independent upstream cancellation wins over a late answer and never resurrects pending state or installs a session grant. Durable-rule failure is reported separately after an accepted immediate approval.

Shared ~/.picasso data (/desktop/*, X5)

The Mac never reads or writes a shared ~/.picasso file from Swift. It calls these owner-only relay routes through RelayClient (the engine's ephemeral port and per-launch bearer, from LocalHelperProcess.ensureRunning()). Companion pct_ tokens get 403 because companionAllows has no desktop case. Every handler goes through @picasso/shared-home (one lock, atomic 0600 writes, schema migrations, NewerSchemaError).

RouteStoreErrors
GET /desktop/settings, PATCH /desktop/settings/permission-mode {permissionMode}apps/desktop-helper/src/desktop-settings.ts over SETTINGS_SPEC (PICASSO_SETTINGS_ROOT ?? ~/.picasso/settings/settings.json). A missing file answers permissionMode: "auto" with permissionModeSource: "default" and writes nothing.400 settings/invalid-mode, 409 settings/newer-schema, 500 settings/unreadable / settings/write-failed
GET/POST /desktop/memory, `POST /desktop/memory/:id[/pin\/unpin], DELETE /desktop/memory/:id, POST /desktop/memory/{clear,gc,init,projections,import-legacy}, GET/POST /desktop/memory/sync, GET /desktop/memory/events`src/desktop-memory.ts over @picasso/memory MEMORY_SPEC (~/.picasso/memory.json v2, per-id merge, tombstones). Answers the Mac's MemoryEntry shape. import-legacy merges the old container and ~/Library/Application Support/Picasso/memory.json once (importLegacyMacMemory). Sync reports the real settings.sync.cloudMemory flag and, when it is on, runs syncLocalMemoryStore for the signed-in account. `/desktop/memory/key[/export\/import\/link] manage the account memory key like picasso memory key status\export\import\link`.409 memory/newer-schema, 500 memory/unreadable, 409 memory/signed-out / memory/missing-key / memory/key-transfer, 502 memory/http-error
GET /desktop/cloud-sessions, GET/PUT /desktop/cloud-sessions/settings, GET /desktop/cloud-sessions/uploads, GET /desktop/cloud-sessions/:id, POST /desktop/cloud-sessions/:id/continuesrc/desktop-cloud-sessions.ts over @picasso/sessions CloudSessionSyncClient and the shared settings.sync.cloudSessions flag. On api-session/status with running: false the relay uploads the session when the flag is on; failures are recorded, never thrown. :id is the redacted transcript (read-only); continue (U3-2) opens a new thread titled <title> (continued) whose first turn is the transcript, and refuses (422, no thread) one importCloudTranscript can't carry over exactly.502 cloud-sessions/* (API), 409 settings/newer-schema, 422 cloud-sessions/unimportable

The account-bound routes (memory sync and key, cloud sessions) use the shared Keychain account session (@picasso/shared-home readAccountSession), never an env token. Full shapes: apps/mac/Picasso/Docs/DesktopAPI.md › Account-bound relay routes.

MCP servers: ~/.picasso/mcp/servers.json (MCP_SERVERS_SPEC, the CLI's store) is folded into the profile's mcp-servers.json before the engine starts and before every /mcp/servers read or edit (src/shared-mcp.ts). Servers the Mac added stay in the profile file; a sidecar mcp-servers.json.shared.json names the shared ones. Toggling or removing a shared server writes the shared file.

Relay hardening (GL-65)

Engine home (DSH_HOME)

<!-- docs-freshness-ignore: legacy-dsh-home --><!-- reason: names the legacy home the one-time copy reads --> Picasso runs its engine on its own home, ~/.picasso/dsh-home (@picasso/shared-home dshHome()), shared by the Mac app and the CLI/TUI and not by the engine's standalone desktop app, whose ~/.dsh folder is a different product's state. The first run there copies providers (profiles/picasso), keys (.credentials.yaml), the default model, and the live threads (sessions, attachments, checkpoints, session-query.sqlite*) from ~/.dsh (migrateLegacyDshHome, copy-only, once, leaving a marker file in ~/.picasso/dsh-home). An explicit DSH_HOME skips it. Provider sync keeps reading ~/.dsh (EngineHome.legacyDshHome) and mirrors its web-profile providers into Picasso's own profile.

Interrupt and queue moves (picassoInbox/*)

The composer has three ways to send while a turn runs: ⏎ queue / ⌘⏎ steer (swapped by "While a turn is running, Enter…") and ⌥⏎ interrupt. With the composer empty and messages queued, ⏎ steers the selected (else the oldest) queued message into the running turn now through the same session/updateQueue steer as its ↑ Steer button, and a second ⏎ sends the next; its row reads "Queued — press ⏎ to send now" (TUI: "queued · Enter sends now"). A refused steer keeps the message queued beside the engine's error. POST /threads/:id/prompt {mode: "interrupt"} calls picasso-mail's picassoInbox/interrupt: the queued turns are lifted out of the next-turn inbox without being canceled, the turn is stopped through the session controller's own cancel (keepInbox), the message is admitted through commands.prompt and the queue goes back behind it, so the interrupt is the next turn and the queue runs after it in order. POST /threads/:id/queue/move {itemId, index} (picassoInbox/move) reorders one queued message in a single inbox splice: same id, text and attachments. The Mac falls back to re-queueing only when an engine answers gateway/invocation-unavailable. The TUI has the same operations on @picasso/sdk-server (session/queue mode interrupt, session/queue/update {kind: "move", index}; ⌃Q interrupts, ⇧↑/⇧↓ reorder). Stop (⌘.) keeps the queued turns in the engine's inbox, but the engine wakes an idle thread only for a new message, so the Mac shows the queue as "Paused · N queued" with Send next: POST /threads/:id/queue/resume (picassoInbox/resume) wakes the thread and the queue runs oldest first, nothing re-queued (test/queue-resume-e2e.test.ts). The TUI's ⌃C stop discards the engine's inbox, so it holds the messages itself, marked paused · send next (click, or ⌃↵ on a row), and sends them one turn at a time after that. Proofs: apps/desktop-helper/test/interrupt-e2e.test.ts and packages/core/test/interrupt-runtime.test.ts (real runtime, a long sleep turn).

Companion phone push (/push/*)

Paired companion devices can opt in to Web Push from the companion page ("Notifications on this device", plus "Send test notification"). src/web-push.ts holds a VAPID P-256 key pair, generated once, and the per-device subscriptions in <stateHome>/picasso-web-push.<profile>.json (0600). The helper encrypts each notice (RFC 8291 aes128gcm) and posts it with a VAPID JWT (RFC 8292) straight to the browser's push service. Only Apple, Google, Mozilla and Microsoft push hosts are accepted as endpoints. A notice is {title, status, body, threadId}: the thread title and a fixed status word (Finished, Needs approval, Has a question), never turn content. Triggers: api-session/status running→idle (top-level threads only), an approval request that is not auto-granted, and a question. Routes: GET /push/key, GET|PUT|PATCH|DELETE /push/subscription, POST /push/test. A companion token acts on its own device only. The owner names the device with deviceId. Revoking or expiring a device drops its subscription, and a 404/410 from the push service drops it too. /pair/sw.js is the service worker (scope /pair, served under the companion CSP).

Resident runner and schedules (stealbear items 9 and 10)

The runner is this relay started by launchd with PICASSO_HELPER_RESIDENT=1 (src/resident-runner.ts), so a turn, a goal or a schedule keeps going when the app quits, closes or updates. The app registers it with SMAppService.agent (Settings › General › "Keep threads running when Picasso quits", off by default; plist Contents/Library/LaunchAgents/com.picassohq.picasso.runner.plist, BundleProgram Contents/Resources/PicassoEngine/runner/picasso-runner); picasso service install loads the CLI's own LaunchAgent (~/Library/LaunchAgents/com.picassohq.cli.runner.plist) for the same relay. It has no parent, mints its own owner bearer and publishes ~/.picasso/run/mac-runner.json (0600; @picasso/shared-home/runner), which the app and the CLI reattach with; launch settings come from ~/.picasso/run/runner-env.json. One runner per engine home: a second one exits 0, the app's own relay exits 3 beside one, and a runner waits for the app's own relay to stop. After a bundle change (Sparkle) or POST /runner/restart it exits 75 once no turn has run for 30 s and launchd starts it from the new bundle. GET /runner / /health runner report its state.

Schedules use the engine's 0.2.2 schedule service as the one store: GET /schedules (items, pause, missed), POST /schedules {title, prompt, timing, target} (target: an existing thread, or a new thread each time), PATCH/DELETE /schedules/:id, POST /schedules/:id/pause|resume|stop, GET /schedules/:id/history, POST /schedules/missed/ack (src/schedules.ts). Runs missed while the Host was down are reported (skipped with a count, late, overdue) until dismissed. The Mac's Schedules page and the TUI's /schedule (over the runner's owner credential) share it; the CLI's stdio profile keeps the engine's own schedule mounts off, so only one Host fires them. Evidence: docs/launch/stealbear/runner-schedules.md.

Host mode (picasso host)

picasso host start runs this same relay and the pinned runtime headless on a machine the person owns (a Mac mini, a Linux box) so the Mac app and other CLIs use it as a remote engine directly over the LAN, a tailnet or an SSH forward. No Picasso cloud is in the path. Code: apps/cli/src/host-commands.ts (commands), apps/cli/src/host-pairing-client.ts + host-attach.ts (CLI client), apps/desktop-helper/src/host-mode.ts + host-main.ts (relay side, PICASSO_HOST_MODE=1), packages/shared-home/src/host.ts (files, dirs allowlist, pair-string format), apps/mac/Picasso/Services/HostPairing.swift + Features/Settings/PairHostSheet.swift (Mac).

CommandDoes
picasso host start [--port 4123] [--bind <addr>] [--advertise <host>] [--dirs <path>…] [--foreground]Starts the relay (main.ts) detached, bound to --bind (default: the tailnet 100.64/10 address, else the first LAN IPv4, else loopback), TLS on the port, owner admin on an ephemeral loopback HTTP listener. Prints a one-time code as CODE@host:port#<16 hex of the cert SHA-256> and a terminal QR of picasso-host://pair?h=&p=&c=&fp=<full SHA-256>. --foreground stays attached (for launchd/systemd). The runtime runs on the Mac app's picasso profile (so the host serves the person's providers); it refuses to start while the Mac app's engine runs on the same engine home (run/mac-helper.json or run/mac-engine.json names a live pid) unless --force.
picasso host pair / stop / status [--paired] / revoke <device>A new code; stop (SIGTERM to the recorded pid only, after checking it still runs the relay entry); online, version, folders, devices (--paired: the hosts *this* machine paired with); drop a device.
picasso host doctornode ≥ 24, runtime == pin, git, gh auth status, free disk, port free/served, reachability (TLS handshake answers with this host's certificate), dirs allowlist, providers (warns when the running host lists no provider with a key, or a stopped host's engine home has none), openssl/cert.
picasso host verify [--engine-dir]Re-hashes the runtime bundle's plugins against engine-manifest.json with this install's engine-verify.mjs (never the bundle's own copy), checks the recorded runtime pin and, on macOS, codesign --verify of bin/node. A checkout has no bundle: pass --engine-dir.
`picasso host dirs add\remove\list`The folders paired devices' threads may use (real paths).
picasso host install-serviceWrites ~/Library/LaunchAgents/com.picassohq.host.plist or ~/.config/systemd/user/picasso-host.service running host start --foreground, and prints the launchctl bootstrap / systemctl --user enable --now line. It never loads or enables it.
picasso attach --pair <CODE@host:port#fp> [--fingerprint] / attach --host <name>Pair, then list threads; later --host <name> [--session] --prompt (no --session: a new thread in the host's first allowed folder) or --host <name> --session --cancel.

Files (all 0600 in 0700 dirs): host side <picassoHome>/host/{dirs.json, devices.json, owner-token, state.json, host.log, tls/cert.pem, tls/key.pem}; client side <picassoHome>/hosts/paired.json. The Mac keeps the device token in Keychain (picasso.engine.<id>.token) and the fingerprint on the engine row.

Security model.

Status surfaces: Settings › Engines shows a paired host with a "Paired host" badge and an Online · <version> · <folders> line from GET /host/status, refreshed with the health poll; the TUI's /doctor appends a "Paired hosts" section (nothing when none are paired); picasso host status [--paired] in the CLI.

PR lifecycle watch (packages/pr-watch)

A thread's pull requests are watched by the relay (apps/desktop-helper/src/pr-watch-routes.ts), not by Swift: the helper runs whenever the app runs (focused or not) and is relaunched with it, and the watch list is the shared <picassoHome>/pr-watch.json (PICASSO_PR_WATCH_FILE overrides). After an app or helper restart the new relay resumes every active watch on its first tick; a TUI on the same Mac reads and serves the same list (a per-watch lease keeps two processes from polling or waking the same PR twice).

Librarian (/librarian, stealbear item 5)

The engine plugin packages/picasso-librarian files threads started without a project (Studio with no folder picked runs a thread in <picassoHome>/workspace). The relay is its face (src/librarian-routes.ts):

Live proof: test/librarian-live.test.ts (opt-in, real engine and model), evidence in docs/launch/stealbear/librarian.md.

Extensions, app connections, X reading and My Work

Background processes in the inspector

GET /jobs[?session=<id>] streams the engine's job/list as SSE, GET /jobs/:id/events follows one job's output, and POST /jobs/:id/kill {sessionId?} stops it. The Mac inspector turns these rows into Background processes: a human title (the agent's description, else a summary of the command), a status line ("Running · 2m", "Completed · 3s", "Failed · exit 1 · 8s", "Stopped", "in <folder>"), groups Running → Failed → Completed (Completed folds past 5), and the full command (Copy) plus its last output on demand. The TUI shows the same list with /tasks and stops one with /tasks kill <jobId>.