Dash browser and backend contracts

Pi Daemon Dash browser and backend contracts

Status: Versioned contract, inventory, transcript projection, ownership, production SPA/workspace, secure browser server, authenticated stream router, embedded and dedicated backends, neutral remote APIs, shadow-TUI transport, dedicated lifecycle, and full same-origin live browser integration are implemented. Final dual-mode visual/security/performance acceptance remains.

Pi Daemon Dash has one browser protocol and one transport-neutral backend seam. The same compiled SPA talks to /dash/v1 whether DashboardServer is embedded inside pi-daemon serve or runs as the dedicated pi-daemon web process. Deployment mode is deliberately absent from browser behavior.

The machine-readable and TypeScript contracts are:

The contract is additive to the existing owner-only NDJSON protocol and the authenticated /v1 session API. It does not replace Pi RPC, expose Pi SDK objects, or introduce a second session state machine. The first concrete core implementation is the owner-safe persisted session inventory, and dedicated backends consume it through the neutral service-bearer Dash API.

Behavioral backend seam

DashboardBackend is the conformance boundary implemented by both deployment modes. It exposes capabilities, inventory, authenticated information, preview transcripts, activation/export tickets, prompt-redacted schedule CRUD/status, managed session lookup, and rich/TUI channels. InProcessDashboardBackend may call transport-neutral services without serialization; RemoteDashboardBackend uses the daemon REST and framed Pi RPC APIs. Neither may bypass generation, controller, idempotency, root, resource, or event-order policy.

DashboardChannel and DashboardTuiChannel are capability-gated peers:

A server advertises each presentation independently. The initial fixture makes Rich available and reports TUI unavailable with interactive-view-seam-required, proving clients must negotiate instead of assuming the shadow view exists. The embedded backend advertises TUI only when a transport-neutral coalesced TUI channel manager is injected; the same browser protocol major remains valid.

Both Rich implementations open dormant durable sessions through the normal catalog/runtime configuration boundary without prompting, then hold a renewable bounded residency lease. The remote backend requests this explicitly with the framed attachment's hydrate=true query; ordinary RPC attaches retain their existing resident-only behavior. Panes for the same session/generation share one controller subscription, upstream attachment, and replay buffer. Mutating commands remain controller-only, read commands remain observer-safe, idempotency keys join only semantically identical commands, and host/session/generation cursors produce an explicit replay gap plus fresh snapshot when stale. Closing the final pane releases controller UI, listeners, replay memory, and residency leases.

Live context usage

Rich channels hydrate context usage through Pi RPC get_session_stats. Pi Daemon projects the pinned SDK's active-branch contextUsage object—estimated context tokens, the selected model's context window, and Pi's percentage—while omitting the private session-file path. Percentages are clamped for display; tokens or percentage remain null when Pi cannot estimate them, including the period immediately after compaction. Missing, malformed, dormant, or unhydrated statistics render as Unknown, never as measured 0%.

The browser clears stale context telemetry on reconnect, replay gaps, compaction, model changes, and tree/session replacement. It refreshes after turn settlement and those identity/context transitions. A refresh is bound to the exact host/session/generation channel, so a late response cannot cross into a replacement session. Context usage is distinct from lifetime message/tool counts and token totals; the browser never reconstructs active context by summing transcript history. No prompt, model output, environment value, credential, provider error, or private path enters this metric.

Browser authentication boundary

The daemon service bearer is server-to-server only. Dedicated Dash loads it privately; embedded Dash calls the same trusted services directly. Browser JavaScript never receives the service bearer and never stores it in a bundle, URL, workspace, IndexedDB, local storage, event frame, log, or error.

POST /dash/v1/login accepts an input-only identity credential and emits an opaque revocable browser session as Set-Cookie. The response body contains only client/workspace correlation, expiry, and a CSRF token. Private HTTP routes and the WebSocket require the HttpOnly, same-site browser session. Mutations also require exact Origin/Host checks and the CSRF header. Loopback is not authentication.

The cookie itself is not represented by a TypeScript response resource or language-neutral browser-storable fixture. Capability negotiation states daemonBearerExposed: false. Authentication failures happen before private route matching, so they do not reveal whether an inventory, ticket, workspace, or managed session exists.

The compatibility provider stores only a digest of the configured web credential and CSRF value. It resolves that proof to the deterministic local-owner administrator. When no provider or web.auth.tokenFile is configured, first launch atomically creates and reuses an owner-only credential at STATE_DIR/web-token; a configured path is held to the same owner/symlink/size checks. An explicit static provider accepts up to 128 bounded identities whose credential bytes are read only from owner-only files or inherited descriptors. Inline/provider-file/CLI/Home Manager configuration contains only metadata and source paths/descriptors. It atomically enables the central multi-user policy mode; no shared-authority intermediate mode exists. Provider removal or principal-role change invalidates subsequent cookie authentication.

The browser cookie contains a random lookup key plus an HMAC; all principal, client, workspace, and expiry authority stays server-side and is bounded and revocable. Bootstrap may return the current principal ID/role/display name as an informational signed-in-as label, but requests cannot send it back as authority. See Dashboard identity and authorization. On a cookie-authenticated GET /dash/v1/bootstrap, the server reproduces the session-bound, domain-separated HMAC CSRF token and returns it in the no-store x-pi-daemon-csrf response header. This restores mutation authority after an ordinary page reload without exposing or storing the owner credential or cookie in JavaScript, local storage, session storage, or IndexedDB. Multiple tabs receive the same bounded token; logout, expiry, and daemon restart still revoke the session. It is HttpOnly, SameSite=Strict, scoped to /dash/, and uses the __Host- form with Secure behind an HTTPS public origin. Restart intentionally revokes all ephemeral browser sessions. Plaintext listeners default to loopback. An explicit non-loopback listener requires a non-loopback public origin plus the validated insecure-HTTP opt-in and emits a warning without relaxing exact Host/Origin/CSRF or login policy. Remote deployments should terminate TLS at a loopback reverse proxy or use native HTTPS/WSS with bounded file/fd certificate sources, exact SNI/Host/Origin, content-free health, HSTS, optional exact loopback proxy-header verification, and atomic last-good certificate rotation. See Dashboard transport security.

GET /dash/ and content-hashed /dash/assets/* are served from the packaged SPA with a deny-by-default CSP, no-sniff/frame/referrer/permissions policy, non-symlink regular-file checks, immutable caching only for hash-named assets, and no SPA-injected credential or configuration. Traversal, unhashed asset names, oversized files, and writable/untrusted files fail closed. The explicit ?fixture=1 story is reserved for deterministic tests and visual artifacts: it is visibly labelled, contains only generated data, opens no production channel, and grants no authority.

Every authenticated route and pi-daemon-dash.v1 frame is mediated by the central policy ledger described in Dashboard identity and authorization. Inventory pages expose only authorized records through bounded, principal/query-bound opaque cursors. Session/ticket/draft/schedule direct lookups return the same content-free not_found result for absent and unauthorized IDs. The remote backend continues to send only the daemon's machine bearer; browser principal data never enters neutral service requests.

HTTP resources and envelopes

The provisional routes are fixed under /dash/v1:

Route Resource and effect
POST /login, POST /logout browser session exchange/revocation
GET /bootstrap capabilities, settings, workspace, and persisted inventory page; no hydration
GET /sessions searchable/paged merged inventory without canonical paths
GET /sessions/{inventoryId} authenticated full information and ownership resource; may include canonical path
GET /sessions/{inventoryId}/transcript normalized active-branch preview or identity-matched unavailable empty resource; no SDK runtime required
POST /sessions/{inventoryId}/activate idempotent reuse/direct/fork/preview-only admission
GET /activation/{ticketId} retained activation ticket
POST /sessions/{sessionRef}/export idempotent export-as-new or guarded append
GET /export/{ticketId} retained export ticket
POST /session-drafts validated durable new-session draft with zero runtime/model/tool work
GET|DELETE /session-drafts/{draftId} inspect or revision-cancel an unsent/materializing draft
POST /session-drafts/{draftId}/send admit one exact first message through durable phase checkpoints
GET /session-draft-send/{ticketId} retained first-send ticket including admitted draft revision and session generation
`GET PUT /workspaces/{workspaceId}`
GET /workspaces, POST /workspaces/select bounded authorized workspace list and server-side browser-session selection
`GET /authorization/{session workspace}/{id}`
`PUT DELETE /authorization/{kind}/{id}/grants/{identityId}`
POST /authorization/{kind}/{id}/transfer atomic ownership transfer with optional retained old-owner role
GET /authorization/{kind}/{id}/audit bounded content-free resource audit window
`GET POST /authorization/{kind}/{id}/controller`
`GET PATCH
GET /diagnostics administrator-only, no-store snapshot of bounded safe daemon policy and normalized failure events
GET /schedules/capabilities effective cron/timezone and schedule validation limits
GET /schedules, GET /schedules/{scheduleId}, GET /schedules/status bounded prompt-redacted schedule metadata and content-free status
POST /schedules, `PUT DELETE /schedules/{scheduleId}`
GET /stream pi-daemon-dash.v1 WebSocket upgrade

Successful JSON envelopes carry:

{
  "dashVersion": "1.0",
  "requestId": "req-...",
  "serverInstanceId": "dash-...",
  "clientId": "client-...",
  "workspaceId": "workspace-...",
  "ok": true,
  "data": {}
}

Errors replace data with the existing safe ApiErrorBody shape. Unknown minor fields are ignored. Bodies, pages, records, and output are bounded before full allocation or serialization. Schedule responses omit the private prompt and return only promptConfigured: true; create requires prompt content, while an update that omits it retains the existing owner-private value. Dedicated mode reads that value only over the server-side service-bearer connection. Older daemons that do not advertise resources.schedules produce the typed schedules_unavailable capability result rather than speculative requests. Likewise resources.sessionDrafts gates lazy creation and resources.diagnostics gates the discreet Diagnostics panel below Settings. The diagnostics snapshot is a bounded service-owned event ring, not raw log access: identifier-bearing paths are normalized, unknown messages are replaced, and prompts/model output/filesystem paths/credentials/environment/request bodies never cross the boundary. Draft resources never contain the private first message; the owner-private store exposes it only to the injected materializer. Cancellation before prompt submission is terminal, while a cancellation racing prompt submission is indeterminate and never blindly replayed.

Preview, ownership, and hydration are separate

Opening a chat pane has three independent stages:

  1. Preview reads a fingerprint-keyed projection from durable JSONL data. TranscriptPage.hydration is literally "not-requested"; preview does not load provider auth, extensions, tools, models, or an AgentSessionRuntime, and never sends a prompt. Every response also carries explicit availability, freshness, and observer-attach truth. A retained memory-only or quarantined generation without a projectable source returns 200, its exact identity, records: [], unavailable freshness, and observerAttachAllowed: false. Clients render that as an empty readonly state and never infer or attach to missing history.
  2. Activation/ownership explicitly chooses reuse, direct, fork, or preview-only. Direct co-opt is never implied by reading. Fingerprints are opaque optimistic preconditions. Frozen tickets cover queued direct, running fork, and succeeded reuse states.
  3. Hydration/attach resolves a managed session and opens a generation-bound rich or TUI channel. Only a granted controller may mutate it.

Direct co-opt accepts ordinary Pi-created source directories that are owned by the daemon user and not group/world writable, even when they are conventionally 0755. This narrow external-source predicate does not apply to daemon state, tickets, manifests, or newly managed session directories, which remain owner-only. The daemon never silently changes an existing external directory or session-file mode; symlinked, foreign-owned, or group/world-writable sources fail closed. Safe fork continues to publish into owner-only managed storage.

Direct and fork activation restore the latest valid model_change and thinking_level_change entries on the source's active branch before a model turn can occur. A bounded owner-configured web.runtimePolicy supplies only the fallback model and explicit trusted tool/resource/settings authority; activation never discovers ambient packages or project extensions. Browser /model commands split the canonical provider/model-id reference and issue the typed set_model RPC operation. Pi RPC extension command descriptors are normalized for completion, while the extension command itself still executes inside Pi's registered-command path without becoming a model prompt.

Export remains a fourth explicit durable operation. Frozen tickets cover a successful export-as-new and an indeterminate append-to-origin. A client never blindly retries an indeterminate activation, command, or export with a new idempotency key.

Inventory pages intentionally omit canonicalPath and raw source fingerprints. The authenticated information resource may include a canonical path, device/inode metadata, and bounded fingerprint. Raw search corpus, system prompts, provider secrets, environment values, and tool output do not belong in the inventory index.

Normalized transcript identity

The preview and live reducer do not key records by array position or rendered text. Every NormalizedTranscriptRecord has a stable recordId and at least one Pi-origin identity in key:

Tool records require toolCallId. An entry_appended event can therefore replace the optimistic/live record rather than append a duplicate. Replay is idempotent. A transcript page names its active leaf and never flattens sibling branches into one false conversation.

The optional branch navigator requests stock get_tree, validates a bounded 10,000-node/depth-256 model, virtualizes rows, and keeps active-path and active-leaf truth separate from selection. Observers may filter, preview, and compare divergent paths. Controller-only fork/edit-resubmit/clone use stock Pi commands. Capability-gated in-place navigate/summarize uses a private, non-replayed tree_navigate result on pi-daemon-rpc.v1; raw pi-rpc.v1 remains the exact upstream union. See Dash session-tree navigation.

Content blocks are bounded text/markdown/thinking/error, authorized image blob references, or numeric usage. Raw base64 images and executable renderers are not part of this contract. Unknown custom records remain visible through a bounded fallback rather than disappearing.

Stream identity, correlation, and replay

Every WebSocket frame carries dashVersion, clientId, workspaceId, and a correlationId; server frames also carry request/server identity. Pane subscriptions have distinct subscriptionId values. Several panes may watch one managed session while sharing one backend channel, but their subscription and command correlations never alias. Frozen multiplex fixtures demonstrate two pane subscriptions to one session with separate correlation IDs.

Every live session event or TUI delta carries:

hostInstanceId + sessionId + generation + sequence + opaque cursor

These values are truth, not display metadata. Cursors are opaque strings scoped to that host, canonical session, and generation. Clients return them for replay or seen acknowledgement and never parse or synthesize them.

A stale host, changed generation, or expired bounded cursor produces replay_gap with snapshotFollows: true. The next subscription_ready contains a fresh atomic snapshot and new high-water cursor. Responses are private to the issuing subscription and are not replayed. A correlated extension_ui_response frame carries only its subscription ID, backend request ID, and bounded JSON response; it is accepted only from the current rich-channel controller and receives a private command-result acknowledgement. A command that lost its response across disconnect is indeterminate; clients reconcile state/entries before deciding whether a new idempotency key is safe.

Declarative extension views

When extensionViews is negotiated, a future compatible Pi RPC producer may emit extension_ui_request(method=render_view). The daemon validates the exact pi-declarative-view v1 document before replay or browser delivery and emits an extension_view event with host-authored provenance. Rich renders only the allowlisted inert tree; TUI and older clients use the mandatory bounded fallback. No extension JavaScript, HTML, CSS, callback, arbitrary URL, or component factory reaches the browser. Actions and form submits are scoped to one exact view ID/revision and remain nested inside the normal request/subscription/controller correlation. See Declarative extension views and extension-view.schema.json.

Controller and idempotency rules

observer and controller are concurrency roles inside one authenticated Dash operator trust domain. They are not user identities. Observers may read and receive live output; mutating operations require explicit controller grant. Disconnect never grants control to a random observer.

HTTP activation/export/workspace/settings/authorization mutations and reconnect-sensitive stream commands carry bounded idempotency/correlation values. The same key and semantic payload join retained work; reuse with a different payload conflicts. The browser must not blind-replay a command simply because its WebSocket closed.

Liveness and attention

DashSessionPresence keeps these facts orthogonal:

Frozen scenarios prove that scheduled+dormant+unread, user-turn+dormant+unread, and running-at-start are representable without overloading one boolean. Merely listing a session does not advance the seen cursor. Successful durable activation advances separate activityAt recency once, reorders the inventory, and never rewrites source modifiedAt or fingerprints. Dash renders schedule filters, countdowns, and the per-session editor only when resources.schedules is negotiated. A scheduled dormant session keeps its dark-magenta dot and countdown regardless of pane focus; an unseen completion adds, rather than replaces it with, the white unread ring. Older compatible daemons receive no placeholder schedule controls or inferred timers.

Presentation switching preserves the reading position

A workspace pane can show one logical session as either the Rich transcript or the canonical TUI. Switching between them hides the inactive presentation layer rather than unmounting it, so composer text, tree state, and channel identity survive the switch. The transcript's reading position is part of that preserved state: returning from TUI puts the reader back where they were, it does not jump to the latest record.

Two rules make that contract hold under a dynamically measured virtual list:

A genuine pane resize still remeasures, and then re-anchors the reader to the same distance from the bottom over the following frames while newly visible rows report their real heights.

Acceptance for this contract must compare settled measurements: a scrollTop read taken immediately after navigation or a switch races the autoscroll and the first measurement pass, so it can compare two pre-hydration zeros and prove nothing.

Workspace and settings

The server-authoritative workspace is a revisioned binary split tree of leaf pane targets. A chat pane identifies an inventory item and Rich/TUI presentation; an information pane identifies only the inventory item. Seen cursors are persisted per workspace. PUT uses both a strong ETag and explicit expected revision/idempotency fields.

Optional sessionDefaults capabilities expose only the effective browser-safe lazy-draft spec and content-free source labels. Pi settings/config paths, package values, explicit runtime resources, and host settings remain server-side; elevated draft policy is rechecked against owner runtime policy on create and materialization. An owner policy may inherit global Pi packages already installed under the server's agent directory. Only the resulting server-side resources are loaded; package declarations, install paths, and installer authority never enter the browser protocol.

Settings distinguish configured defaults from a mutable UI-only runtime overlay. The overlay may change presentation preferences such as theme, editor, sidebar, transcript expansion, reduced motion, bounded browser cache, and editor.submitKey. The default enter policy sends on Enter and inserts a newline on Shift-Enter; mod-enter preserves multiline Enter and sends on Cmd/Ctrl-Enter. It cannot change bind/auth/TLS, roots, credential references, daemon resource limits, or trusted runtime policy.

The implemented stores use owner-only directories and atomic owner-only JSON files under STATE_DIR/web. Workspace layout/seen cursors and settings overlays have strong ETags, explicit revisions, bounded retained idempotency receipts, strict unknown-field rejection, count/depth/record byte limits, serialized updates, and corruption quarantine followed by safe configured defaults. Runtime settings report the source of every effective leaf (default, config, or runtime); reset removes only the runtime overlay.

Negotiated default limits

DASH_DEFAULT_LIMITS is the safe initial envelope. A server returns every effective value in capabilities; all caches must also enforce count, byte, age, and single-record limits.

Bound Default
HTTP body / WebSocket frame 1 MiB each
per-connection outbound queue 4 MiB
connections / subscriptions per connection 64 / 32
in-flight commands per connection 8
inventory / transcript page 100 / 200 records
inventory roots / indexed sessions 32 / 10,000
inventory index / one record / max age / reconcile 64 MiB / 16 KiB / 60 s / 30 s
search query / transcript record 1,024 chars / 512 KiB
projection source / line / entries / output 256 MiB / 1 MiB / 100,000 / 64 MiB
projection cache count / bytes / one entry / age 1,024 / 256 MiB / 64 MiB / 7 d
image preview / authorized blob response 256 KiB / 8 MiB
extension view bytes / nodes / depth / text 256 KiB / 256 / 16 / 128 KiB
extension actions / fields / options / images 32 / 32 / 128 / 16
browser tree nodes / depth / projected text 10,000 / 256 / 2 MiB
replay events / one event / bytes / retention 512 / 512 KiB / 2 MiB / 5 min
workspaces / one workspace / panes / depth / pinned sessions 64 / 1 MiB / 32 / 16 / 8
TUI rows / columns / changed rows / delta 200 / 320 / 200 / 512 KiB
settings resource 256 KiB
visible lease heartbeat / expiry 20 s / 60 s
browser transcript cache count / bytes / one entry / age 64 / 64 MiB / 8 MiB / 1 d
browser session TTL 12 h

A negotiated server may reduce capacity, but it must not omit a bound. A slow browser is disconnected instead of growing memory or blocking other readers.

Performance acceptance budgets

DASH_PERFORMANCE_BUDGETS and the capabilities fixture encode the Phase 0 local acceptance targets for 10,000 indexed sessions:

Measurement p95 budget
persisted-index bootstrap response < 50 ms
first sidebar rows < 150 ms
loaded-index search page < 100 ms
cached transcript viewport < 150 ms
cold normal transcript useful viewport < 500 ms
Pi stream delta to reducer < 50 ms
shadow-TUI row delta < 50 ms
normal animation/render frame work < 16 ms

The initial production SPA gzip budget is 1.5 MiB (1,572,864 bytes). These are measurement contracts, not license to do unbounded synchronous work just below a wall-clock threshold. HTTP requests must not perform O(total sessions) scans, and browsers must not construct O(total entries) DOM trees.

Compatibility rules