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:
src/dashboard-contract.ts / package export
./dashboard-contract — backend, channel, resource, frame,
limit, and performance types;src/dashboard-fixtures.ts / package export
./dashboard-fixtures — fresh, deterministic fixture
builders for backend and frontend conformance suites;src/dashboard-backend.ts / package export
./dashboard-backend — policy- preserving
InProcessDashboardBackend, coalesced rich channels, replay,
controller arbitration, durable hydration, and renewable residency
leases;src/dashboard-remote-backend.ts / package export
./dashboard-remote-backend — service-bearer REST delegation
plus coalesced framed-RPC/TUI channels, bounded reconnect, cursor/gap
recovery, and indeterminate accepted-command handling for dedicated
mode; its internal transport is split across
src/dashboard-remote-transport.ts (shared client, limits,
error taxonomy, and bounded frame decoding),
src/dashboard-remote-rich-hub.ts (framed-RPC attachment),
and src/dashboard-remote-tui-hub.ts (shadow-TUI
attachment), which are internal modules without their own package
subpaths;src/dashboard-auth.ts,
src/dashboard-store.ts, and
src/dashboard-server.ts / matching package subpaths —
credential exchange, signed revocable browser sessions, static SPA
serving, strict request admission, bounded WebSocket handoff, and atomic
workspace/settings state;src/dashboard-stream-router.ts / package export
./dashboard-stream-router — explicit backend-to-browser
channel routing factory; the server remains fail-closed unless its
lifecycle injects this handler after cookie auth;web/src/browser-dashboard-client.ts — browser-only
same-origin REST and multiplexed pi-daemon-dash.v1 client,
including input-only login, CSRF, controller channels, replay/reconnect,
and indeterminate lost responses;dashboard-api.schema.json
— JSON Schema for HTTP resources and multiplexed WebSocket frames;dashboard-api.openapi.json
— same-origin HTTP and WebSocket route contract; andfixtures/dashboard-api/ — frozen language-neutral
examples.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.
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.
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.
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.
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.
Opening a chat pane has three independent stages:
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.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.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.
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:
entryId for persisted tree entries;messageId for live/partial message identity; ortoolCallId for tool start/update/end identity.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.
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.
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.
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.
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.
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:
scrollTop. Absolute offsets are not comparable across a
remeasure, because dynamic row measurement changes the transcript's
total size for the same reading position; andA 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.
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.
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.
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.
1.x additions are optional fields, enum values guarded
by capabilities, or new frame/resource kinds older clients can
ignore./dash/v2, a new schema ID, and a new
WebSocket subprotocol.DashboardSessionChannel.