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)
- No mock or fallback engine responses:
apps/desktop-helperandapps/macmust never synthesize fake assistant turns, stub tool outputs, or silent in-memory fallbacks when the engine host is unreachable or returns an error. - Every thread operation (
POST /threads, prompt submission, streaming events, approvals, checkpoints, schedules, workspace diffs, model selection) routes through the live engine host (the host and remote-client modules plussrc/relay.ts). Errors surface explicitly with actionable diagnostics.
---
2. Key Components
| Path | Responsibility |
|---|---|
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.ts | node: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.ts | Helper 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.swift | Launch 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.mjs | Stages 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/PicassoTests | Native 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
- Not sandboxed. The app ships as a Developer ID app with the hardened runtime and no App Sandbox.
Picasso/Entitlements.plistholds onlycom.apple.security.device.audio-input; the bundled Node binary is signed withScripts/PicassoEngine.entitlements(cs.allow-jit). There are no sandbox temporary exceptions. - Real homes (D2).
EngineHomeresolves the account home fromgetpwuid(getuid())and gives the engine Picasso's own engine home~/.picasso/dsh-home(DSH_HOME; see "Engine home" below) and~/.picasso(PICASSO_HOME, settings undersettings/, memory atmemory.json). These are the CLI's directories, so sessions, providers, settings, MCP servers and memory are shared on one Mac. Debug builds honorPICASSO_DSH_HOME/DSH_HOME/PICASSO_HOME; Release ignores the environment. Dev and test relays keep a throwaway home (rule 3 below). - Container migration (D3).
ContainerDataMigration.runIfNeeded()runs once before the engine starts and moves a sandboxed build's~/Library/Containers/com.picassohq.picasso/Datainto the real homes. Every rule is copy-if-absent or merge (credentials and memory merge through the helper); nothing in the real home is overwritten, the container stays as a rollback copy, conflicts are logged, and the marker~/Library/Application Support/Picasso/migrations/container-v1.jsonmakes later launches a no-op. - Ephemeral port.
LocalHelperProcesslaunches the helper withPICASSO_HELPER_PORT=0and a fresh per-launch bearer, so the relay binds an ephemeral port on127.0.0.1. The app reads the port from thereadyevent; nothing listens on a fixed port. (Run by hand,main.tsdefaults to4001; a remote engine keeps its configured port.) On a crash the app relaunches the helper with backoff (1/2/4/8/16 s), andThreadsServicereconnects with the new port and token. - No containers. The app, the helper and their tests need no container runtime or local database; the engine is a child process of the app.
2b. Auth scopes
| Scope | Credential | Reach |
|---|---|---|
| owner | Authorization: Bearer $PICASSO_HELPER_TOKEN (per launch, env only) | Every route, including /desktop/*, /pair/*, research and POST /shutdown. |
| companion | Paired-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-attach | The discovery-file token, header only, from a loopback peer only | LOCAL_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
- `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-reportsandpicasso-librarian. The relay does not loaddsh/picasso-ecosystem.cordis.patch.yml,dsh/picasso-sdk.patch.ymlordsh/picasso-ecosystem.community.patch.yml(main.tsacceptsPICASSO_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-servermcp-*,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. - 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 withdisabled: true. - Throwaway `DSH_HOME` in dev/tests: Automated tests and dev helpers must isolate
DSH_HOMEto a temporary directory (unlessPICASSO_HELPER_DSH_HOME=operatoris explicitly set) so test sessions never pollute the operator's~/.picasso/dsh-home/sessionssidebar. - Typography & Dense UI: All SwiftUI views in
apps/macmust strictly follow/Users/gurbakshchahal/fonts/README.mdand/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).
| Route | Store | Errors | ||||||
|---|---|---|---|---|---|---|---|---|
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/continue | src/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)
- SSE backpressure:
writeSsechecksres.write; a client with more than 1 MiB queued gets oneevent: overflowframe and the stream ends. Clients reconnect with their cursor. - Companion tokens: paired-device tokens expire 30 days after pairing (
PAIR_DEVICE_TTL_MS); an expired token is a 401 and its row is dropped./pair/devicesshowsexpiresAt. Rows saved before this change get an expiry on load. - `?token=` authenticates
GET …/eventsstreams only (the one place a browserEventSourcecannot send a header). Every other route needs theAuthorizationheader. - `/pair` CSP: every companion asset is served with
default-src 'self'; script-src 'self'; style-src 'self'; frame-ancestors 'none'; …plusnosniff. The page has no inline script or style (companion.cssholds the styles). - Host header: a request whose
Hostis not loopback, the bound address (every LAN IPv4 address when bound to a wildcard), the advertised pairing origin, or a name inPICASSO_HELPER_ALLOWED_HOSTSgets421 host_not_allowed(DNS-rebinding guard). List a TLS terminator's name (for example a tailnet host) inPICASSO_HELPER_ALLOWED_HOSTS.
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).
| Command | Does | ||
|---|---|---|---|
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 doctor | node ≥ 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-service | Writes ~/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.
- Transport. The relay already refuses a non-loopback cleartext bind (AO-006.13), so the host always serves TLS, with a self-signed P-256 certificate made once by
openssl(825 days). There is no CA: clients pin the certificate's SHA-256. The QR carries the full fingerprint; the typed string carries the first 16 hex (64 bits), which the client checks against the presented certificate and then pins in full. The CLI first reads the certificate without trusting it, refuses on mismatch (nothing sent), then sends every request with that certificate as the only CA plus a fingerprint check inside the handshake. The Mac does the same in aURLSessionDelegate(PinnedCertificateDelegate) for requests, streams and the/healthprobe. Hostname checks are replaced by the pin, so the certificate survives address changes. - Pair codes. 8 characters from the unambiguous 30-letter alphabet, minted only by the owner bearer over loopback, single-use (burned on first presentation, hit or miss), 10-minute TTL, at most 8 outstanding, held only as SHA-256, and
POST /host/pairallows 5 failures per source IP per minute (429 after). - Device tokens. A successful exchange returns a per-device
phd_token (32 random bytes); the host stores only its SHA-256 indevices.json. It is valid untilpicasso host revoke, which also ends that device's open streams. The owner token never leaves the host. - Scope. A
phd_token reaches the thread, interaction, provider and workspace routes a Mac or CLI needs, plusGET /host/status; never/shutdown,/pair/*,/host/pair/codes,/host/devices*,/desktop/*(the host's account data),/research/*or/push/*(403scope_denied). The Mac hides "Stop engine" on a paired host; stop it on the host. - Folders. Every absolute path a device sends (
cwd,path,root,workspaceRoot, … at any depth of the body, and?cwd=/?path=) must be insidedirs.jsonafter resolving symlinks on the longest existing prefix; otherwise 403host/dir-not-allowed. A new thread withoutcwdstarts in the first allowed folder; with none allowed it is refused (host/no-dirs).GET /threadsshows a device only threads inside the allowlist. The owner (the host's own CLI) is not confined. The runtime's own sandbox still governs what a turn's tools may touch. - Host header.
PICASSO_HELPER_ALLOWED_HOSTSgets the advertised name and the machine's host name, so the DNS-rebinding guard accepts them. - Slow first boot.
host startlets the runtime take up toPICASSO_DSH_STARTUP_TIMEOUT_MS(default 180 s) to report its launch URL. A fresh engine home boots in about 2 s on an idle Mac (cold and warm are the same: no first-boot install or index work); the engine's start is CPU-bound module loading, so it stretches with machine load (about 85 s was seen on a heavily loaded machine). The subagent approval loader runs as an in-threadmodule.registerHookshook; the old off-threadmodule.registerhook cost about 2.4 s of CPU per boot.
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).
- Detection.
api-session/statusrunning=false → the relay reads that session's engine log and watches every PR agh pr createtool call printed (or/pr confirmjournalled asplugin:picasso/pr-opened) since the last scan. The Mac's "Create PR" button registers its PR withPOST /threads/:id/pr {url}. - Polling.
gh pr view --json statusCheckRollup,reviews,reviewDecision,mergeable,mergeStateStatus,comments,…with the user's own gh auth: every 60 s while checks run (or while a young PR has none yet), every 5 min otherwise, exponential backoff to 30 min on errors; merged/closed stops the watch. - Wake. When all checks finish (one combined result, failed checks with a trimmed
gh run view --log-failedexcerpt), a new review arrives, or the PR becomes conflicted, the thread gets ONE labelled user-role prompt throughsession/prompt(the engine inbox). "Wake threads on PR updates" (settings.wakeOnPrUpdates, default on): on →queue(an idle thread starts a turn); off →steerinto a running turn, or held on the watch and steered into the thread's next turn. A failed delivery is held and retried. - Routes.
GET /threads/:id/pr→{sessionId, watches, wakeOnPrUpdates};POST /threads/:id/pr {url}|{repo, number};DELETE /threads/:id/pr/:number; SSEpr-status {sessionId, watches};PATCH /desktop/settings/pr-wake {enabled}.
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):
- Routes.
GET /librarian→ the engine'slibrarian/getview{status, paused, budgetReached, today{looks, skipped, tokens, filed}, dailyTokenBudget, model?, working?, next[], nextCount, recent[]};POST /librarian/pause|resume|look-all;POST /librarian/budget {dailyTokenBudget}. An engine without the plugin answers 404librarian/unavailable(never a made-up idle state). - SSE.
librarianwith the same view on the host-wide stream (GET /interactions) whenever<picassoHome>/state/librarian.jsonchanges, from any process (the Mac engine, a TUI engine, the TUI's own controls). The relay watches the file (event-driven, no timer). - Filed threads.
GET /threadsrows of threads the librarian filed carry the project folder ascwdandfiledBy: "librarian"; the workspace snapshot and stream list a thread filed into a workspace in that workspace'ssessionIds(the engine itself keeps a thread in the workspace of the folder it was created in). - Engine updates.
GET /desktop/engine-updates(owner only) is<picassoHome>/state/engine-update.jsonas Next (the pending switch, each surface's next check) and Recent (the update log), the shape@picasso/shared-home/background-workdefines for every background worker (stealbear item 12). It backs the Engine updates card in Settings › General; the TUI's/updatesreads the same file.
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
- Extension approvals.
GET/POST /extensions/approvalslists and records the third-party plugins and apps the person approved once (the hosts they reach and the keys they read), shared with the CLI through<picassoHome>/state/third-party-consent.json. The older name of the same route still answers for the CLI. The Mac Extensions page shows each item's measured per-turn cost ("Every turn 142 tokens · On demand 1.2k tokens"; a warning above 3,000 tokens) and its data access ("Reaches: <hosts> · Reads: <keys>"). - Connect an app.
GET/POST /mcp/appsinstalls or removes the curated app connections (Blender, Figma, browser automation, Xcode, iOS Simulator, and x.md, the only remote one, labelled third-party) into the shared~/.picasso/mcp/servers.json. The CLI'spicasso mcp apps|add|removeand the TUI's/mcp add [app] [--yes]write the same file. - X reading.
/desktop/settings/x-reader[/key|/check],/desktop/x-reader/previewand/desktop/x-reader/command(src/desktop-x-reader.ts) back the Settings card, the X-link paste card and/xin the Mac app. The key lives in the Keychain; X links the person opens are sent to the third-party x.md service (mdfromx.com). - My Work.
GET /artifacts?cursor=&limit=&cwd=&kind=(src/work-index.ts) pages the files a person's threads made, newest thread first; a paired host device sees only its allowed folders. It backs the Mac's My Work page and "Make a similar one"; the CLI'spicasso work list|similarand the TUI's/workread the same index. - History import.
/desktop/import/*runs Settings › Import (Claude Code, Codex, OpenCode) and marks imported threads onGET /threadsrows;picasso importis the CLI form.
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>.