# Pi Session Daemon

## Purpose

This module is the Pi-owned session-daemon foundation. A detached local daemon process creates an `AgentSessionRuntime` with the Pi SDK, tracks it in a registry keyed by Pi session identity and cwd, keeps it behind a private local socket, and maps a small event subset to the PiChamber IPC contract. Browsers never connect to it. Directory inputs must be absolute except for the exact `~` global-session sentinel, which the daemon expands to the server user's home directory before validation. Other relative paths remain invalid.

## Entrypoints

- `../extension-protocol.js` owns the bounded form descriptor and response validation shared by the private daemon bridge and public route projection, so both sides of the IPC seam accept the same field contract.
- `extension-bridge.js` owns extension UI bindings, pending dialog lifecycle, normalized status/widget/panel/app state, snapshot projection, and extension event publication. `session-daemon.js` supplies runtime lookup, publication, redaction, and directory dependencies through its narrow factory interface.
- `builtin-extensions.js` owns the list of Pi built-in extensions PiChamber loads (`codemode`, `tool-search`, `mcp`) and the list it skips on purpose (`llama.cpp`). Pi ships these inside the SDK package and only the Pi CLI loads them by itself, so the daemon passes them through `resourceLoaderOptions.extensionFactories` with the same `builtin: true` and `replaceable: true` flags the CLI uses. They load as `builtin:<name>`. `-builtin:<name>` in the Pi `extensions` setting disables one. Pi leaves one out when an installed extension registers the same tool, command, or flag, so a third-party MCP extension that registers `/mcp` takes over and Pi does not read `mcp.json`. MCP servers come from `mcp.json` in the agent directory and `.pi/mcp.json` in a trusted project. Each resident session runtime starts its own servers on `session_start` and stops them on `session_shutdown`, which every runtime disposal path emits. The MCP extension resolves `mcp.json`, `mcp-auth.json`, and `mcp.log` through the SDK's process-wide agent directory, so `daemon-process.js` sets `PI_CODING_AGENT_DIR` to the daemon's `--agent-dir` when one is given. Code that creates a daemon in-process with a custom `agentDir` must set that variable itself. `codemode` and `tool_search` are registered inactive. The MCP extension activates `codemode` for servers with the default `codemode` exposure and `tool_search` for `deferred` servers; `defaultTools` can activate them without MCP. Tool rows follow Pi's design: a `direct` MCP tool is an ordinary row rendered by its own `renderCall` and `renderResult`, `tool_search` is an ordinary row with the default display, and a `codemode` call is one row whose rendered result lists the nested calls with their status while the script runs. Nested calls stay out of the row list because their events carry `parentToolCallId`. `/mcp` runs its non-TUI path because the bridge reports `mode: 'rpc'`: `/mcp` sends server status through `ctx.ui.notify`, and `/mcp login <server>`, `/mcp logout <server>`, and `/mcp reconnect <server>` act directly. The interactive manager needs `ctx.ui.custom()` and is not available. PiChamber has no MCP settings screen.
- `extension-name.js` owns path-free extension display names for `extensions.list` and the `source` of runtime `extension.error` events. A single-file extension is named after its file; an `index` entry point is named after its nearest non-generic directory, or the package root for package extensions. Neither surface ever carries a server path.
- `extension-tool-render.js` owns daemon-side rendering for Pi extension tools that define `renderCall` or `renderResult`. Built-in tools are excluded so PiChamber retains native tool card rendering. Global Pi TUI theme symbols are installed once without overriding an existing theme. Partial updates render both the collapsed and expanded result slots (the browser draws the expanded slot only when a row is expanded), are throttled at 250ms with a trailing timer and component `invalidate()` trigger, the latest live render of a running tool is kept and included in session detail so a client opening mid-run sees the working state, settled results are rendered only for messages a history page selects (never for the whole branch) and memoized per toolResult message for history replay, and rendered ANSI lines are bounded to 100 columns, ≤200 lines, and ≤2000 characters per line. Its exported `renderComponentLines` helper carries those same bounds for message renders.
- `extension-message-render.js` owns daemon-side rendering for `pi.registerMessageRenderer(customType, renderer)`. The renderer is resolved from the owning session's `extensionRunner` (a missing lookup function or a throwing lookup means no render) and called once collapsed (`expanded: false`) and once expanded (`expanded: true`) with `outputPad: 0` and the same extension theme tool renders use; each component renders at 100 columns through the shared line bounds, and every rendered line passes through attachment redaction. Live `extension.message` events and replayed `custom_message` history entries both carry optional `render: { message, messageExpanded? }` beside `customType`/`details` (public routes re-apply the same 200-line/2000-char caps and drop malformed shapes). Replay rebuilds the `{ role: 'custom', customType, content, display, details, timestamp }` input from the persisted entry so the renderer sees identical input live and on replay; renders resolve lazily for only the history page actually returned (the same pending-render mechanism as settled tool renders) and are memoized per entry. A missing/throwing renderer, a falsy component, or an empty collapsed slot omits `render` entirely; a failing or line-identical expanded slot omits `messageExpanded`. Renderer failure never breaks publication: the message still goes out with text/details, and other messages and sessions are unaffected. Only displayable messages (`display !== false`) render.
- `session-daemon.js`
  - `createPiSessionRuntime({ cwd, agentDir })` creates a persistent Pi runtime using the selected server-side cwd and Pi agent directory. It uses Pi's normal settings, credentials, models, sessions, skills, prompts, context discovery, and native extensions. Every runtime creation binds extensions through `session.bindExtensions()` in rpc-like mode with a daemon-owned UI bridge, so pi extensions load exactly as they do under the Pi CLI's RPC mode. Sessions created outside PiChamber (Pi CLI or any other writer of the cwd-scoped JSONL directory) are the same records: `sessions.list`/`open` hydrate from `getEntries()`, then overlay the live `session.messages` / `state.streamingMessage` turn while `isStreaming` is true, including an unpersisted user prompt that has not flushed to JSONL yet. A later prompt must overlay live turns onto that log rather than replace it. An unmatched `toolCall` projects as `running` only while `isStreaming` is authoritative; the same unmatched call in an idle persisted turn projects as a timestamped interruption error instead of malformed `completed` state. `getSession` also reports `isStreaming` and `lifecycle` so a restarted UI can restore the working/tooling state without waiting for the next token.
  - Extension bridging: blocking dialogs (`ctx.ui.select/confirm/input/editor`, plus the PiChamber-specific `ctx.ui.form` which resolves with a values object keyed by field id) publish an `extension.dialog` event, register with the pending-input index (which publishes the canonical `session.input` event right after, so every device learns the session is blocked without opening it), and stay pending until answered by the `extensions.respond` private command; every answer, timeout, abort, session close, or daemon stop publishes an authoritative `extension.dialog.dismiss` event before resolving the extension so every connected client removes the modal (timeouts resolve defaults, abort resolves cancellation, form answers travel as a sanitized `values` record and are rejected unless required fields, number bounds, select options, and checkbox values match the pending form); fire-and-forget calls publish live-only `extension.notify` (`{ message, level }`, level `'info'` unless `'warning'`/`'error'`; never stored, so late devices see no notice history), `extension.status`, `extension.widget` (supporting both string arrays and component factory functions rendered daemon-side with a 100-column `tuiStub` and 100ms coalesced `requestRender()` throttling, with automatic lifecycle disposal and error capture), session-scoped `extension.working` (`setWorkingMessage` / `setWorkingVisible` with control character sanitization and 200-char cap; `setHiddenThinkingLabel` and `setWorkingIndicator` remain intentional no-ops because PiChamber uses its own turn working header and full thinking presentation), session-scoped `extension.editor` (`mode: 'set' | 'paste'`, default `'set'`), and `extension.title`; extension errors publish `extension.error`; displayed custom messages publish `extension.message` (with daemon-side `render.message`/`messageExpanded` when a message renderer is registered; see `extension-message-render.js`), and appended `pichamber.*` custom entries without a stable `id` publish `extension.entry` and appear as one-off extension-role transcript items; any other `appendEntry` custom entry is private extension state (Pi's TUI never renders it) and is neither published nor projected into history. `pichamber.ui` entries with a stable `id` and `pichamber.app` entries are mirrored into normalized live panel/app state (`extension.ui` / `extension.app` events live; latest wins per id, `removed: true` unregisters) and are excluded from transcript messages to avoid duplicate rendering. `ctx.ui.getEditorText()` uses a lazy server-side mirror: the first call on a session marks it as tracked, emits an `extension.editor.track` event (`{ enabled: true }`), and returns the current mirror text (or `''` initially); the browser composer observes tracking through session snapshots and the event stream, immediately syncing its current draft and then debouncing subsequent draft changes by 250ms via `POST /api/pi/sessions/:id/editor-draft` with monotonically increasing revisions to reject out-of-order deliveries; prompt submission resets the mirror text to empty while preserving tracking; `setEditorText` and `pasteToEditor` update the mirror synchronously; and session close, deletion, reload, or daemon disposal clears both the mirror and the tracking flag and publishes `extension.editor.track` with `{ enabled: false }` so browsers stop syncing until a later call opts the session back in. Untracked sessions never allocate mirror state (a prompt reset only touches an existing mirror), and draft syncs refresh the session's idle-disposal timer without reactivating a disposed runtime. `ctx.ui.theme` implements the complete Pi SDK `Theme` interface (`fg`, `bg`, `bold`, `dim`, `italic`, `underline`, `inverse`, `strikethrough`, `getFgAnsi`, `getBgAnsi`, `getColorMode`, `getThinkingBorderColor`, `getBashModeBorderColor`, `appearance`, `colors`, `style`), encoding semantic colors as standard truecolor SGR escape markers (`38;2;1;1;<index>m` and `48;2;1;1;<index>m`) using canonical `THEME_COLORS` and `THEME_BG_COLORS` lists that must stay strictly synchronized with `packages/ui/src/lib/pi/ansi.ts`. `appearance` returns a fixed `'dark'` default because the daemon serves multiple clients with different themes. `colors` exposes a frozen token map. `style(text, options)` maps token names and text attributes to marker escape sequences. Extension live state (statuses, widgets, panels, apps, titles, and working indicators) is kept in per-session maps and included in both `sessions.open` and `session.snapshot`; snapshots and details also carry daemon-owned `inputState: { pending }` from the pending-input index (`null` when authoritatively nothing is pending), as does every `sessions.list` row (with `sequence`) and the `sessions.pendingInput` command; on session runtime creation/open, restart, or branch mutation (`sessions.navigate`, `sessions.fork`, `sessions.clone`), live panels and apps are rebuilt by replaying the active branch's persisted `pichamber.ui`/`pichamber.app` custom entries in order without publishing per-entry events. The open response must cover every extension event at or below its `lastSequence`, so first attach cannot skip one-time startup statuses before the browser stream connects; reconnect snapshots rebuild the same live panels. TUI-only `setHeader`, `setFooter`, and `custom` remain intentional no-ops because PiChamber provides web/GUI message framing and composer docks rather than terminal chrome; standard RPC editor replacement/insertion and window titles are projected to the focused browser session. Pending dialogs are cancelled on session deletion or daemon stop so no extension thread blocks forever; idle disposal no longer cancels them, it waits while a hosted request is pending (see below). Unknown dialog responses return `{ resolved: false }` rather than throwing so a stale client retry cannot tear down the shared socket.
  - `createSessionDaemon(options)` starts/stops the authenticated local socket daemon and validates its endpoint and setup inputs. Test seams inject `createRuntime` for resident runtimes and `createServices` (default `createAgentSessionServices`) for transient per-mutation prompt services.
- `session-replay.js` owns the byte- and count-bounded contiguous replay suffix (`MAX_REPLAY_EVENTS`, `MAX_REPLAY_BYTES`, `createSessionReplayLog`). It caches one serialized line per event for broadcast/replay reuse and reports contiguity (`canReplay`) without trusting filtered delivery.
- `daemon-process.js` is the detached private-process entrypoint. It reads the daemon credential from its owner-only sidecar, starts the socket, writes non-secret identity state only after readiness, persists an accepted ownership claim before acknowledging it over IPC, and removes only its own state on signal shutdown.
- `ipc-client.js` is the server-only authenticated request client. It owns JSONL framing and never exposes endpoint or credential values to callers.
- `runtime-registry.js` owns active runtime identities and session-local subscriptions. It rebinds the subscription after an SDK session replacement, rejects identity conflicts rather than displacing another runtime, and attempts every tracked disposal even when another disposal fails.
- `session-engines.js` owns the Pi-neutral session-engine seam: engine id/label validation (`'pi'` reserved), the routed session-command set plus the handler-command set, the host service shape, and the ordered registry with per-engine timeouts, abort signals, in-flight sharing, redacted rows, failure isolation, and secret-free logging. Main registers zero engines, so the seam is inert until factories are registered.
- `session-engine-router.js` owns engine dispatch, create targeting, listing merges, snapshot payloads, the redacting publish wrapper, and the `engines.list` description. It takes the registry, directory resolution, send-dedup input, redactor, framing, publication, the pending-input index, and the sequence sampler as injected dependencies so `session-daemon.js` keeps thin call sites.
- `pending-input.js` owns the daemon-level pending-input index (`PENDING_INPUT_KINDS`, `MAX_PENDING_INPUT_COUNT`, `normalizePendingInputSummary`, `createPendingInputIndex`). It folds daemon-hosted blocking requests and engine-reported summaries into one derived per-session summary (`{ count, kind, since }`, `null` when authoritatively nothing is pending), publishes the canonical `session.input` event (`{ pending, serverNow }`, with `serverNow` sampled from the injected `now` at publish time) only on actual change, and prefers hosted requests when both sources exist. It is synchronous with no timers; idle re-arm on last-settle is an injected callback.
- `engines/index.js` is the single engine registration point: one exported array of engine factories. It ships empty in main.
- `session-jsonl.js` validates cwd-scoped Pi JSONL before discovery. List, create, and daemon startup require a valid session header per file; they do not read whole transcripts. `sessions.list` uses `listPiSessionJsonlDirectory` (header, first user prompt, latest rename from the file tail). Files in one directory are listed with at most eight concurrent reads; one unreadable or header-malformed file still fails that directory. Full-file validation remains on open of a specific session so a malformed body still fails that chat. Pi's SDK list is not the production list path because it scans every line of every log. Directory names match Pi's SessionManager encoding, including Windows drive letters and Git Bash/MSYS/Cygwin/WSL `/c/...` paths so PiChamber and the Pi CLI share the same JSONL sessions.
- `supervisor.js` resolves server-only overrides, creates the credential, serializes start/reuse/stop through an owner-only lock, validates daemon identity through `runtime.health`, forwards server-owned commands only after that identity check, and starts or stops the detached process. Reuse requires the ready-state sidecar `entrypoint` to match this checkout's `daemon-process.js`; a healthy same-profile daemon with a different entrypoint is claimed and then shut down through authenticated IPC so local `bun run dev` cannot keep answering `getSession` without the live-turn overlay. When the parent is Electron, the child is spawned with `ELECTRON_RUN_AS_NODE=1` so packaged Windows/macOS/Linux apps run the daemon as Node instead of a hidden second Electron GUI process. A daemon spawn failure (synchronous throw or the child's async 'error' event, e.g. when the executable vanished) is captured, never left as an uncaught 'error' event, ends the readiness wait early, and surfaces as code-only `DAEMON_SPAWN_FAILED` without the executable path.
- `../server/server-profile.js` derives one stable daemon profile per server: installed web is `web-p<port>`, development web is `web-dev-p<port>`, installed desktop is `desktop`, and development desktop is `desktop-dev`. `PICHAMBER_SERVER_PROFILE` overrides the derivation with a hashed `custom-<hash>` key so raw input never becomes a path, while preserving whether the launcher is a development build for stale-code replacement. The profile key is stable across restarts; the server instance id is a fresh UUID per launch. Development launchers (`scripts/dev-web-hmr.mjs`, `scripts/dev-web-full.mjs`, `cross-env` in the `dev:server` scripts, `PICHAMBER_ELECTRON_DEV`) explicitly mark themselves so stopping development can never terminate the installed daemon.
- `../server/cross-process-lock.js` serializes critical sections across servers sharing one data directory (exclusive create, owner nonce, dead-pid steal, bounded wait). It backs the daemon operation lock pattern and every shared read-modify-write store below.
- `session-lease.js` owns exclusive cross-daemon residency of one Pi session, keyed by canonical agent directory, cwd, and session id under `<agentDir>/.pichamber/locks/sessions/`. The lease is acquired before a resident `AgentSessionRuntime` is created and released after idle disposal, deletion, replacement, or daemon shutdown. Opening or deleting a session leased by another live daemon returns `SESSION_IN_USE` (HTTP 409) while the session stays visible in listings; different sessions remain fully concurrent. Lease files from different `PICHAMBER_DATA_DIR` values still coordinate because they live under the shared agent directory.
- `../routes.js` registers authenticated public adapters before the generic proxy. It exposes only whitelisted runtime, project, session, provider-auth/configuration, settings, resource, small-model utility, and SSE event shapes; no browser response contains daemon paths, credentials, PIDs, headers, or socket framing. Session actions are path-selected, so an identity supplied in a request body cannot redirect a mutation. `POST /api/pi/sessions` projects the created detail through the same session-detail projector as `GET /api/pi/sessions/:id`, so extension items appended from `session_start`, the `streamEpoch` stamp, and extension UI snapshot state are returned rather than rejected; only a malformed detail is `DAEMON_PROTOCOL_MISMATCH`. `archive-store.js` persists PiChamber-only opaque session archive metadata atomically beneath the PiChamber data root and never edits Pi JSONL. `settings-store.js` keeps PiChamber new-session defaults separate from Pi settings.json. `/api/pi/small-model/generate` reads that sidecar and runs a bounded in-memory no-tools Pi session, so utility generation creates no visible or persisted session and exposes no model or credential details. `GET /api/pi/settings` copies leftover UI/desktop `defaultModel` / small / walkthrough strings into that sidecar when those sidecar fields are unset, then Sessions settings is the only editor. Sidecar `defaultThinkingByModel` is the per-model thinking map (`provider/model` keys). A stored global `defaultThinking` with a default model is copied onto that map key and dropped; global thinking without a default model remains a clamp-only fallback. `ui-settings-store.js` owns portable PiChamber preferences at data-root `settings.json` and host, device, and secret-bearing values at `runtime-state.json`. `/api/pi/ui-settings` merges both views for existing clients and splits writes through explicit field allowlists. It migrates an unscoped flat file once; after the portable scope marker exists, local fields found in `settings.json` are ignored rather than applied to the host. The route is not available through removed `/api/config/*` aliases. `session-folders-store.js` validates and atomically persists the restored sidebar's folder snapshot at `/api/pi/session-folders`; a missing snapshot is distinct from an empty one. `custom-themes.js` serves validated custom-theme candidates from `/api/pi/themes` with per-file partial-failure isolation. `model-config-store.js` atomically updates Pi's credential-blind `models.json`; literal keys continue through Pi auth and headers are write-only. `providers.config.get` returns `config: null` for a provider that is not in `models.json` (built-in catalog entries) and fails only when that file is present but malformed.
- `../event-stream.js` owns the `/api/pi/events` SSE connection lifecycle so `../routes.js` stays a thin adapter. Per connection it installs cleanup before `subscribe` is awaited (a client that leaves during the opening window cannot leak a daemon subscription; a late subscription is closed exactly once), funnels client disconnect, subscription error, and server shutdown through one idempotent cleanup, hands an `AbortSignal` through the supervisor to the private IPC subscriber, never writes a named heartbeat or a late frame to a dead response, and byte-bounds the response socket buffer (`res.writableLength`, default 8 MiB, aligned with the daemon replay budget) with the bound enforced BEFORE each write so no single frame overshoots it. A client that stops reading is disconnected recoverably — the stream ends and the client reconnects from its last accepted sequence for replay or an authoritative snapshot, so no event is silently removed from an otherwise contiguous stream, but frames still buffered when the disconnect fires are not promised as delivered; the reconnect replay is the delivery guarantee — while a healthy client's connection stays independent of the slow one. Every teardown is bounded: a `write()` that returns false starts a drain deadline (default 5s) that ends even a quiet never-draining client, and after `res.end()` a terminate deadline (default 5s) destroys the response, because `end()` alone only queues the FIN behind unflushed data and would otherwise leave a stalled socket open forever and hang server shutdown/close. The response `'error'` guard stays attached for the response's lifetime (idempotent; cleanup can complete while data is still in flight and a late unhandled `'error'` would crash the process). The registry tracks live connections so `registerPiRuntimeRoutes` can expose `closeEventStreams()` for server shutdown, ending active and still-opening streams (and their daemon subscriptions) before the HTTP server closes. The IPC subscriber removes its abort listener and destroys the opening or active daemon socket on cancellation.
- `*.test.js` uses temporary cwd, agent, data, and runtime directories; it never loads a developer's Pi home, credentials, or sessions. On Windows the private endpoint is a named pipe, not a `.sock` file, and POSIX mode-bit assertions are skipped.

## Protocol and security invariants

- Each profile owns its daemon artifacts: `<dataDir>/pi/daemons/<profileKey>/` holds the credential, `daemon-state.json`, and `operation.lock`; the endpoint is `<runtimeDir>/pi-daemons/<profileKey>/daemon.sock` (or `$XDG_RUNTIME_DIR` equivalent); per-profile logs append to `<dataDir>/logs/pi-daemon-<profileKey>.log`. The state sidecar records `profileKey`, `serverInstanceId`, `serverPid`, `daemonId`, `runtime`, and `buildId` alongside pid, endpoint, and entrypoint. A pre-profile legacy daemon is never adopted, unlinked, or signaled; only its dead pid record is best-effort removed.
- The endpoint is a Unix domain socket on POSIX or a Windows named pipe. TCP endpoints are rejected. Listeners and clients connect with IPC `path` options so Windows `\\.\pipe\pichamber-pi-session-daemon-<owner-key>-<profileKey>` works the same as a POSIX socket path. `PICHAMBER_PI_SESSION_DAEMON_ENDPOINT` still overrides the socket path, but the credential, state, and lock stay per-profile: a profile never adopts, unlinks, or signals an endpoint it cannot authenticate and identify as its own, and the conflict surfaces as `DAEMON_ENDPOINT_UNVERIFIED`.
- POSIX socket parent directories are mode `0700`; the socket is created mode `0600`. An existing endpoint fails startup rather than being unlinked without daemon-identity verification. Windows named-pipe ACLs are not yet equivalent owner-only OS permissions; credential authentication remains mandatory.
- The daemon credential and non-secret state sidecars are mode `0600`; their per-profile parent directory and the operation lock serialize start/reuse/stop. Exclusive lock create writes the owner pid in the same step. A leftover empty or malformed lock, a dead owner pid, or Windows `EPERM`/`EACCES` on exclusive create is retried and stolen after a short freshness window instead of failing as `DAEMON_LOCK_UNAVAILABLE`. Sidecar `chmod` failures on Windows are ignored after the file exists so readiness is still recorded. Health identity includes the daemon PID, profile key, and daemon id, and all three must match the state sidecar before the supervisor reuses a process. Reuse also requires the ready-state `entrypoint` to equal this host's `daemon-process.js`; a missing or different path is not identity-compatible and is replaced after a claim.
- Ownership transfers through authenticated `runtime.claim` only after the previous server PID is no longer live. The daemon persists the new `serverInstanceId` and `serverPid` to its state sidecar before acknowledging the claim, so its in-memory owner and discoverable owner cannot split. Shutdown goes through authenticated `runtime.shutdown` (profile, daemon id, and current owner instance must all match) before any signal; a stale server whose daemon was claimed receives `DAEMON_OWNERSHIP_MISMATCH` and terminates nothing. A direct signal is used only after authenticated health and ownership checks prove that the sidecar PID is this profile's daemon (or for a child the supervisor just spawned during failed startup).
- Shared writable state is coordinated, not merely namespaced. Pi prompt, context, settings, and model writes serialize through `<agentDir>/.pichamber/locks/pi-config.lock`; `models.json` additionally serializes in its store. PiChamber data-root stores (`pi/settings.json`, `pi/snippets.json`, `pi/session-archive.json`, `pi/session-folders.json`, root `settings.json` and `runtime-state.json`, STT config, remote-client and pairing stores) serialize read-modify-write through per-file locks with atomic replace; `session-folders.json` additionally rejects a stale `updatedAt` snapshot as `SESSION_FOLDERS_STALE` (HTTP 409) instead of silently overwriting newer folders. Passkeys, the JWT secret (exclusive create with re-read), the tunnel token, and git identities use atomic replace. Cross-daemon config staleness is bounded by idle runtime disposal (five minutes), which recreates runtimes from disk; there is no cross-daemon live-reload generation yet.
- The public health route deliberately whitelists its response fields (state, protocol version, capability names, and the opaque public `streamEpoch`). It never returns a daemon endpoint, credential, PID, cwd, agent directory, or transcript data. Public transcript and event projections preserve daemon-redacted assistant error messages so the UI can distinguish provider/runtime failures instead of reducing every failure to `ASSISTANT_ERROR`.
- A client must send an `authenticate` frame carrying the host-private daemon credential before it can issue a request. Credentials are never logged or returned.
- Frames are LF-delimited JSON. One frame has a 16 MiB maximum. Complete frames are parsed before the client bounds the remaining incomplete frame. Invalid JSON reports `MALFORMED_DAEMON_RESPONSE`; an oversized response reports `DAEMON_RESPONSE_TOO_LARGE`. A malformed, oversized, unauthenticated, or invalid request closes the connection.
- The private core commands are `runtime.health`, `projects.list/select`, `providers.list/config.get/models.set/models.add/status/login/login.respond/login.status/logout`, `settings.get/set`, `resources.list/update/prompts.create/prompts.update/prompts.delete`, `extensions.list/respond`, and session list/pendingInput/create/open/messages/rename/delete/tree/navigate/fork/clone/prompt/steer/followUp/sendReceipt/abort/model/thinking/compact operations. `sessions.pendingInput` takes no arguments, is never session-scoped or routed to engines, and returns `{ sessions: [{ sessionId, directory, pending }], sequence, streamEpoch }` for sessions with a non-null summary; `runtime.health` advertises it. Resource lists use opaque IDs and never disclose server paths. Only top-level Pi prompt templates and applicable AGENTS.md sources are editable; package/path resources and skills are browse-only. Prompt-template mutations resolve against the caller's explicit effective directory, reject stale and cross-directory opaque IDs, enforce project trust for project prompts, serialize create/update/delete operations, preserve existing files on validation or write failure, atomically reserve new destinations without overwriting concurrent writers, use temporary-file persistence with cleanup, preserve unknown frontmatter fields (for example `argument-hint`) when editing description or content, and for rename validate the destination name and scope, reject collisions, write the destination atomically, remove the source only after success, and roll back the destination when source removal fails. A committed prompt mutation never disposes a session runtime: affected idle sessions use Pi's identity-preserving `session.reload()`, while streaming, compacting, and extension-command sessions finish first and reload at their next safe lifecycle edge. Global prompts refresh every resident runtime; project prompts refresh only runtimes in that directory. Mutation responses merge freshly discovered editable prompts from transient per-mutation services (never retained; Pi 0.84.1 exposes no dispose on AgentSessionServices/ModelRuntime/ResourceLoader/SettingsManager — file reads plus a cleared create-time timeout, no watchers/intervals/listeners, so dropping the reference lets GC reclaim it) with live package/path/extension resources so the Settings page reflects the committed file even while runtime activation is deferred. `providers.refresh` touches only live runtimes; the returned catalog always comes from the active runtime via `providers.list`. Generic non-prompt resource writes share the serialized resource-mutation queue and commit through an atomic replace even while a session is active. The daemon returns `deferred: true` and recreates resident runtimes after they become idle; a new session reads the committed file immediately. If immediate recreation fails after a committed write, the response still reports deferred activation and the daemon keeps the recreation queued for a later safe edge. Project trust changes are persisted in Pi trust.json and use the same deferred runtime recreation; project-local extensions load through Pi's own trust flow once trust is resolved. Provider login submits API keys only across authenticated private IPC or brokers Pi's OAuth/device/manual-code interaction, passing the same `getDeviceId` login option as the Pi CLI (backed by the runtime `SettingsManager.getOrCreateDeviceId()`) because flows such as Sign in with ChatGPT reject logins without a stable installation ID. A failed attempt projects Pi's error message (with its causes, URL query strings stripped so a pasted callback never echoes an authorization code, capped at 500 characters) beside the stable `PROVIDER_AUTH_REQUIRED` code; public projections contain only opaque login IDs, prompts, URLs/device codes, model catalog/configuration, and stable states, never credentials or write-only headers. `providers.models.set` atomically persists Pi's `models.json`; a busy runtime returns `deferred: true` and is rehydrated after all active sessions are idle, while a new session sees the catalog immediately. `providers.models.add` appends one model (`id` required; optional `name`, `reasoning`, `thinkingLevelMap`, `input`, `contextWindow`, and `maxTokens`) atomically without replacing the provider. The model configuration store owns normalization and validation. The daemon rejects duplicate or unavailable live models, seeds a missing file provider only from authoritative live metadata when safely representable, rejects extension registrations with an explicit `models` array, and uses the same deferred recreation as `providers.models.set`. A successful recreation publishes a provider-catalog invalidation so connected settings views load the new authoritative catalog. Provider edits preserve unexposed existing model metadata server-side while public projections remain credential-blind. `providers.list` projects only catalog and authentication-status metadata, including Pi-supported `thinkingLevels` (`null` map entries hidden, `xhigh`/`max` opt-in). Session thinking overrides are clamped to the live model's supported levels. `sessions.list` checks each cwd JSONL header (not the full transcript) so a project with large session logs cannot spend tens of seconds on startup. SDK `SessionManager.list` is not used for that path; `listPiSessionJsonlDirectory` reads a header, a bounded prefix for the first user prompt, and a tail for the latest rename. Each listed row whose runtime is resident in this daemon (including unpersisted resident sessions) carries `live: { lifecycle, sequence, retry?, runStartedAt?, serverNow }`, sampled synchronously with the current event `sequence` from the same retry/streaming/run-start state as snapshots; rows without a resident runtime carry no `live`, which clients treat as unknown rather than idle. Every Pi row additionally carries `inputState: { pending, sequence }` from the pending-input index (`pending` is `null` when authoritatively nothing is pending, stamped with the same observed sequence as `live`), so every device learns blocked sessions without opening them. Concurrent lists of the same directory share one in-flight result. Concurrent `sessions.open` of the same id share one in-flight runtime create; a duplicate that finishes later disposes itself and returns the registered runtime instead of `SESSION_RUNTIME_CONFLICT`. Full-file JSONL validation stays on open of a chosen session. The browser explicitly selects an existing user-chosen project directory before listing it; the daemon's startup cwd is an internal fallback, not a user-visible default project. Unnamed persisted sessions use their first prompt as a readable list title when available. `sessions.open` and `getSession` project the last assistant `provider`/`model`/`thinkingLevel` onto the session record so reopening a chat keeps that cache identity. `sessions.open`, navigate, fork, and clone return a tail page bounded by both projected-message count and serialized bytes. `sessions.messages` reads older pages with an opaque before-cursor. A page that starts inside a tool loop repeats the owning user message as a grouping anchor; browser merging deduplicates that overlap. Persisted tool results are indexed across the active branch before paging, so a paged assistant tool call still receives its matching final result. Session selection validates a persisted JSONL path before Pi opens it; inactive mutations are never directed by a browser-supplied path. `sessions.open` first checks a caller-supplied directory through its bounded header list, so an ordinary project-scoped open neither walks every directory in the agent store nor adopts a same-id session from another directory. Directory-less deep links resolve by Pi's `timestamp_<id>.jsonl` filename before broader SDK-list and header-scan fallbacks. Directory-wide JSONL checks on list/create/startup are header-only; full-file validation stays on open of a chosen transcript. Attachment paths originate in the server-local upload map and cross only private IPC into Pi's tool context. Pi may persist or echo those paths in its authoritative JSONL transcript, so every public projection redacts them recursively: session titles/previews, user and assistant text/thinking, tool arguments, errors, snapshots, and live message events never expose the server-local path. Persisted projection first checks for the attachment marker, then redacts matching tokens with a delimiter scan whose work stays linear for large encoded strings and tool payloads. The daemon maps Pi message lifecycle, deltas, tools, queue changes, model/thinking, compaction, interruption state, and session title updates (`session.updated` on create-with-title, first-prompt auto-title, and rename) to canonical events. Session deletion publishes an explicit `session.deleted` event; archive and directory moves keep the id and never publish it. Pi `model_select`, `thinking_level_changed`, and `session_info_changed` events are authoritative for model, thinking, and session-name changes made by extensions, the TUI, or another client. The bridge observes Pi provider registration and label writes because Pi 0.84.1 emits no corresponding session events: provider mutations publish a catalog invalidation, and label mutations invalidate the bounded session-tree projection. `ctx.reload()` clears old extension UI state, reloads Pi resources, and invalidates provider/resource/command catalogs. `ctx.shutdown()` disposes only the requesting idle session runtime after its command or turn settles; it never stops the shared daemon or unrelated sessions. Compaction events preserve Pi's manual/threshold/overflow reason, active/retrying/terminal phase, retry timing, redacted failure, compact-and-retry intent, and pre/post token estimates. The latest state is included in `getSession` and reconnect snapshots; after daemon restart, the latest persisted Pi `CompactionEntry` restores completed feedback without treating history as live work. Tool events carry redacted input arguments, cumulative partial-output snapshots, final output, error text, renderer metadata (edit diffs, truncation notes), and server start/end timestamps plus a `serverNow` sample so clients with different clocks can normalize elapsed time. Nested tool execution events carrying a non-empty `parentToolCallId` are skipped because Pi records nested calls on the parent tool result and omits them from transcript messages. Active and recently completed tool parts retain their start timestamps in the daemon's bounded in-memory timing cache, so `getSession` hydration keeps timers consistent across clients and browser reloads until the daemon restarts. Pi loads skills with the ordinary `read` tool. The daemon matches those read paths against the active runtime's authoritative `resourceLoader.getSkills()` catalog and adds path-free `metadata.pichamber.skill.name` for live events, hydrated history, and reconnect snapshots. PiChamber uses that metadata for its compact Skill row and Settings navigation; an arbitrary file named `SKILL.md` remains a normal read. Persisted `toolResult` entries merge into their assistant `toolCall` parts as output/error/end time so hydrated sessions render complete tool cards; tool-result entries never surface as standalone messages. Assistant message projections and start events carry the owning user-message id so the restored turn renderer can group and display replies; all assistant entries in a tool loop and every retry/compaction continuation remain attached to the latest user turn until `agent_settled` closes the run. Assistant message projections also pass through Pi's per-turn `Usage` (input, output, cacheRead, cacheWrite, totalTokens, and the same five numeric cost fields). The daemon sanitizes the raw payload through `projectUsage` before publication: every numeric must be finite and non-negative, the whole object is omitted when any field is missing or wrong type, and unknown keys are never copied. The `assistant.message.end` event carries the same sanitized usage so the last turn updates without waiting for a full `getSession` refetch. The public route whitelist re-applies the same sanitizer on each projection so a stale or malformed payload from a different daemon version cannot surface as `NaN` or `Infinity` in the reducer.- Live `message_start` events use daemon-generated browser message IDs because Pi has not appended the corresponding session entry yet. Pi agent-core emits streaming assistant starts as shallow copies, not as the finalized object it later persists. At `message_end`, `message-entry-aliases.js` therefore rebinds the synthetic ID to the finalized event object, then maps that object by identity to Pi's appended entry in a microtask. `sessions.navigate` and `sessions.fork` resolve the mapping after stripping any part suffix. Alias keys include cwd and session ID, survive idle runtime disposal of persisted sessions, and clear on explicit session deletion, confirmed ephemeral expiration (which publishes `session.deleted`), or daemon shutdown. Hydrated message projections continue to use the Pi entry ID directly.
- Each event and reconnect snapshot has a monotonic sequence. Replay is capability-negotiated: the subscriber stamps its cursor with the `streamEpoch` it was established under, a marker-less cursor fails as `DAEMON_PROTOCOL_MISMATCH`, and a cursor from a retired epoch resyncs from a snapshot instead of replaying — even when this daemon's sequence numerically overtook the old cursor, replaying from it would silently skip the head of the new space. Every event, snapshot, and health result carries an opaque random `streamEpoch` regenerated on every daemon process start (never derived from daemon id, profile key, pid, or any private identity); `runtime.health` advertises the `events.streamEpoch` capability, session reads stamp it so clients can reject stale-epoch responses, and a snapshot published because the cursor could not be replayed is additionally stamped `resync: true` as a recovery baseline. A reconnect replays the retained contiguous suffix after its cursor when available, otherwise it receives an authoritative `session.snapshot`; unavailable runtimes are not represented as empty sessions. The suffix lives in `session-replay.js` and is bounded by both count (1,024 events) and encoded wire bytes (8 MiB): eviction removes whole oldest events first and never truncates content, each event is serialized once and the cached line is reused for live broadcast and replay, and live delivery stays full-fidelity even for events too large to retain. The byte budget counts encoded wire bytes, not heap, and snapshots assign sequence numbers without replay insertion so numeric gaps from private snapshots are intentional. A single oversized event clears the retained suffix without being retained, an empty ring never replays, and a cursor beyond the latest sequence resynchronizes from a snapshot; contiguity is judged on the global suffix while delivery stays session-filtered, so a filtered client never replays through a gap. A missed suffix recovers through `session.snapshot` plus `sessions.open` hydrate, which re-reads persisted entries for tool finals and lifecycle.
- Prompt, steer, and follow-up requests acknowledge acceptance only after the Pi SDK preflight signal (`session.prompt` with `preflightResult(disposition)` where `disposition` is `'started'`, `'queued'`, or `'handled'`); the daemon owns the still-running turn promise and publishes any asynchronous rejection as `session.error`. A preflight rejection (`session.prompt` promise rejecting without calling `preflightResult`) propagates as the request rejection so dedup never caches it as accepted and the operation id stays reusable. Plain text uses prompt() with expandPromptTemplates:false and source:extension plus images; slash input keeps source:rpc. Pi can emit the live user-message start before that message is branch-readable, so the daemon queues each accepted prompt's path-free file metadata per delivery kind (`prompt`/`steer`/`followUp`) and adds it to the matching user start. SDK `queue_update` shrink is the authoritative owner of the next user start (steering drains before followUp regardless of send order); direct starts emit no shrink and fall back to oldest-by-generation. Requested `steer`/`followUp` always travels as SDK `streamingBehavior` (ignored while idle) so an idle arrival that races another sender still queues. Retention is decided at resolution from the SDK preflight disposition: `'queued'` keeps the metadata, `'handled'` and registered extension commands never do, and `'started'` keeps it only while the session is still streaming; only queued resolutions keep metadata until their per-delivery start, all other completions clean a still-pending entry (an extension command that never emits a user start). The browser can render the attachment footer immediately without exposing bytes or server paths, while later hydration remains authoritative. An extension command may resolve without starting an agent turn, so completion of its non-streaming prompt promise publishes the idle lifecycle boundary that Pi does not emit as `agent_settled`. Pi notifies extensions of model switches (`model_select`) but not session subscribers, so that same completion edge also reconciles a changed live model into `session.model` before the idle boundary; thinking already emits `thinking_level_changed` to subscribers and needs no reconciliation. A registered extension invocation is not used for automatic first-prompt naming: an extension-only empty session remains unnamed unless the extension names it, and the first later conversation prompt supplies the derived title. Classification uses the active Pi runtime's registered extension commands, not a browser claim. Manual compact requests likewise acknowledge startup instead of holding the public/private request open for the summarization call; the daemon-owned promise reports its outcome through `session.compaction`. A second compact request while Pi is compacting fails as `SESSION_BUSY`, and optional `/compact` instructions pass to `AgentSession.compact(customInstructions)`. A later prompt on the same session invalidates an older send rejection so it cannot error the new turn. If the rejected send left Pi streaming, the daemon aborts it. Steer/follow-up against an idle session starts a new prompt instead of `SESSION_NOT_RUNNING`. Agent and compaction completion are never held behind the 30-second public/private request budgets. Pi retryable provider failures remain live: an `agent_end` with `willRetry` does not publish terminal `session.error`, and `auto_retry_start` projects Pi's attempt, next-attempt time, and redacted error text through `session.lifecycle: retry`. Retry metadata is retained in reconnect and `getSession` snapshots until the next `agent_start` begins the attempt. `agent_settled` remains the authoritative idle boundary after retry success, exhaustion, or cancellation and clears the run's user/assistant ownership.
- Send-intent deduplication (`send-operation-registry.js`) owns the authoritative execution boundary for prompt/steer/followUp: the client labels each intent with a stable `operationId` and the health-verified `streamEpoch`, the daemon claims `kind + sessionId + operationId + streamEpoch` BEFORE Pi activation, and an identical same-epoch retry returns the original acceptance receipt (`{ accepted, messageId, deduplicated: true }`) without invoking Pi again. A concurrent duplicate of a still-pending intent shares the original outcome; the same full key with a different payload (text, model, thinking, message id, or attachments) fails as `OPERATION_PAYLOAD_MISMATCH` (HTTP 409). An intent from another daemon lifetime fails as `STALE_STREAM_EPOCH` (HTTP 409) before Pi or attachment-consumption side effects. The same id on a different kind or session is a different intent and executes independently, never a mismatch. Only acceptances are retained: request-path rejections happen before Pi runs (nothing executed, id reusable) while pending duplicates still share the original rejection, and every claim settles so pending duplicates never hang. Retention is bounded: receipts expire after `sendOperationTtlMs` (default ten minutes) and at most 1,024 accepted receipts are retained (oldest evicted to a bounded expired tombstone); pending claims are bounded separately (256 entries, never expired while active, new distinct pending past the bound fails as `SESSION_BUSY`). An expired or evicted id reports `expired` and a retry with the same id fails as `OPERATION_EXPIRED` (HTTP 410), never an automatic re-execution — callers must use a new operation id. `sessions.sendReceipt` is the exact read-only receipt lookup for an uncertain send: it requires the full `kind + sessionId + operationId + streamEpoch` identity; a missing epoch returns `unknown`, never invokes Pi, and returns `accepted` (retained receipt), `pending`, `expired` (seen but retention gone, outcome unknown, never assume success), or `unknown` (never seen in this retention window, tombstone pressure, post-restart, or rejected before Pi ran). The registry is in-memory per daemon process with bounded tombstones (`expired` degrades to `unknown` under pressure): there is no lifetime receipt guarantee, only within the retention window. A restart loses receipts, but the replacement daemon rejects the retired epoch instead of replaying the old intent. Receipt lookup and replay protection remain separate read-only contracts.
- Registry keys combine the SDK session ID with its cwd. A replacement subscription is rebound to the replacement session so old-session events cannot be published under the new identity. A collision is a visible error; it never replaces or disposes an unrelated runtime.
- After `agent_settled` or a terminal compaction that will not resume the turn, the daemon disposes an idle runtime after five minutes (injectable for tests). View-only access (`sessions.open`, `sessions.messages`, `sessions.tree`, plus navigate/fork/clone/abort/model/thinking/rename settlement) arms the same lifetime through `touchIdleDisposal`, so browsing old sessions without prompting cannot grow the registry unboundedly while the UI keeps only a bounded transcript cache. Disposal never deletes Pi JSONL; when the assigned session file exists on disk, the daemon remembers the selected session file and recreates that runtime on the next private prompt or open. When the assigned JSONL is positively absent (stat reports ENOENT only, determined after dispose while the resident lease is still held and before any release), the session is ephemeral: after a successful lease release the disposal clears matching dormant state plus the same safe per-session auxiliary state as explicit deletion where applicable (messageEntryAliases for cwd/session, retry/compaction/run-start/shutdown) and publishes the existing `session.deleted` event so connected clients remove it. Publication happens only after a successful release, which closes the cross-daemon recreate race. A missing/throwing getSessionFile or a non-ENOENT stat failure cannot prove absence and never claims deletion, a failed disposal retains ownership (registry entry, lease, and global runtime) without emitting deletion, and persisted sessions keep dormant state for reopen. The touch clears first and re-arms only when the session is resident and idle-safe, so a read cannot race a pending operation and a repeated open extends the deadline. Agent and compaction starts cancel pending disposal, and prompt/compact entry clears it before activation. The timer refuses to dispose a session while `isStreaming` or `isCompacting` is authoritative, while a send is in flight (`activeSessionInputs`), while a resource reload is pending or running, while a provider retry is scheduled, while compaction is running/retrying, or while a hosted input request is pending in the pending-input index; a dialog opened while the session is idle (for example from a command or event handler) therefore survives the five-minute timer so a user who walks away finds it still pending later. When the last hosted request settles, the index re-arms the idle timer through the disposal touch, and disposal proceeds as usual. Deletion and daemon stop still cancel dialogs with an authoritative dismiss event. A read or prompt that arrives during an in-flight disposal waits for it and reopens from JSONL instead of adopting a dying runtime. There is deliberately no acquisition-path eviction: capacity pressure alone never disposes entries that are actively mounting.
- Ephemeral sessions: Pi's `SessionManager` defers JSONL creation until the entries contain a user or assistant message, so `sessions.create` alone stays ephemeral (untouched sessions vanish on restart by design). A rejected first prompt likewise persists nothing: the runtime stays resident and retryable until normal idle disposal, which reports the session as deleted when its assigned JSONL is positively absent (see above). Failed `sessions.create` cancellation and lease-acquire failures dispose without persisting and release (or never hold) the lease. Model/thinking setup failures after lease acquisition dispose first and release only after successful disposal: a disposal rejection retains `{ runtime, cwd, sessionId }` in daemon-owned pending failed-create state without releasing ownership, registering the runtime, or installing dormant state, while still throwing the original model/thinking error; `sessions.delete` retries its session entry and daemon stop retries all entries (dispose-first, release-after-success, failed retries stay pending without releasing ownership, entries removed only when still current, disposal exactly once).
- A startup validation failure writes only a stable failure code to the owner-only non-secret state sidecar. It never records a transcript, session path, or credential. The supervisor surfaces that code as unavailable instead of claiming an empty or idle session.
- After a forced crash, the next health, request, or subscription probe re-runs supervised startup. Each private health probe uses the supervisor's five-second operation budget; it must not inherit the general 30-second command timeout and hold every browser request until the browser aborts. Daemon readiness has its own 15-second bounded budget, separate from the shorter probe, lock, and shutdown budget, because loading Pi settings and provider/model catalogs can legitimately exceed five seconds. The supervisor removes an existing POSIX socket only when its recorded daemon PID is dead, the endpoint is a socket, and authenticated IPC deterministically refuses or times out. An endpoint that responds, authenticates incorrectly, returns malformed protocol data, or cannot be classified remains `DAEMON_ENDPOINT_UNVERIFIED`.
- The web server creates its supervisor after HTTP listen so the profile key uses the bound port, then warms it without awaiting Pi provider/model initialization. A first request arriving during that warmup shares the supervisor's `startPromise`. The bound profile key travels back over the `pichamber:ready` IPC message and into the CLI instance file; `pichamber logs` uses that key to include the matching private daemon log, while `--json` and `--quiet` serve output still carries only the port.
- The daemon owns the runtime after clients disconnect. Disconnecting the web-server client cannot stop active Pi work.

## Session engines

The daemon accepts an `engines` array of engine factories (`(host) => engine | Promise<engine>`, validated synchronously as `INVALID_ENGINES` when not an array of functions). `engines/index.js` is the single registration point and ships empty, so with no engines every routing branch is guarded by "registry has engines" or "an engine owns this session" and the zero-engine path runs exactly the Pi-only code: same responses, events, and frames.

An engine implements `id` (lowercase alphanumeric/hyphen, never `'pi'`), optional `label` (non-empty, at most 64 characters, defaulting to the id), sync `ownsSession(sessionId, directory?)` / `ownsProvider(providerId)` answered from its own index, async `listSessions(directory, { signal })` / optional `listProviders({ signal })` in Pi row/provider shape that must throw on failure, a `handlers` map for session-scoped commands, sync `snapshot(sessionId)` that must include an absolute `directory` (otherwise it is treated as unavailable), and async `dispose()`. Handler keys must belong to the routed session commands plus `sessions.create`; an unknown key, a non-function value, or a missing `handlers` object is `INVALID_ENGINE`, as are a bad id/label or shape. Detail handlers (open/messages/navigate/fork/clone/create) return a Pi-shaped detail; prompt/steer/followUp return `{ messageId }`; anything else returns a result object or undefined. A routed command with no handler fails as `ENGINE_UNSUPPORTED_OPERATION` (HTTP 409) automatically; engines never write it themselves. `commands` in `engines.list` is the sorted handler keys. Engines own their own event publication (`session.deleted` after delete, `session.updated` after rename) through the host `publish(event, payload, sessionId, directory)` wrapper, which requires an absolute `directory` (never falling back to Pi's active project), redacts attachment paths, rejects `session.snapshot`, missing ids, and a missing/relative directory as `INVALID_ARGUMENT`, strips spoofable `sessionId`/`directory` payload keys, and otherwise shares the global sequence, stream epoch, and replay log, so engine events are indistinguishable on the wire and replay to reconnecting subscribers. The host also exposes `prepareAttachments(attachments)`, the same preparation Pi prompts use: it returns `{ text, images, files }` where `text`/`images` carry server-local paths and file bytes for the native agent only (never for published events or results; redaction remains as a backstop) and `files` is path-free metadata. An engine publishing its own live user start publishes `assistant.message.start` with `role: 'user'` and includes that `files` metadata as path-free `file` parts (`{ type: 'file', id: `${messageId}:file:${index}`, index, mime, filename }`), the same shape Pi uses, so the browser shows the attachment footer immediately.

Routing is ownership-first: a command in the engine command set (open/messages/rename/delete/tree/navigate/fork/clone/prompt/steer/followUp/abort/setModel/setThinking/compact) with an explicit `sessionId` dispatches to the first owning engine in registration order and skips the Pi idle guard entirely; unowned sessions fall through unchanged, and Pi's default-to-active-session fallback never selects an engine. `extensions.draft` on an engine-owned session fails as `ENGINE_UNSUPPORTED_OPERATION` without touching Pi. Creation targets an engine via explicit `engine` (`'pi'` forces Pi, unknown ids fail as `INVALID_ARGUMENT` even with zero engines) or via an owned `model.providerId`, stamping `session.engine` on the detail. An engine without a `sessions.create` handler cannot be a create target: an explicit id fails as `ENGINE_UNSUPPORTED_OPERATION` while provider routing skips it and falls back to Pi. Prompt-family commands run through the same send-dedup boundary, so operation ids, receipts, `STALE_STREAM_EPOCH`, and `OPERATION_PAYLOAD_MISMATCH` behave identically and `sessions.sendReceipt` works unchanged; a result without a `messageId` fails as `MALFORMED_DAEMON_RESPONSE`. `engines.list` returns `{ engines: [{ id, label, commands }] }` (`{ engines: [] }` with zero engines) and `runtime.health` advertises `engines.list`; `GET /api/pi/engines` projects the same shape through a whitelisting projector and maps daemon errors without returning an empty list. Listings append engine rows (tagged `session.engine`, directory forced) after Pi rows and engine providers after Pi providers, skipping id collisions (a collision marks that engine failed); `incompleteEngines` names failed engines and appears only when non-empty. Failure is never empty success: a throwing, timed-out (10s default), or malformed engine listing drops all that engine's rows, and the current browser catalog does not yet act on `incompleteEngines`, so a UI that renders engine sessions must treat such a listing as non-authoritative for those engines before relying on destructive cleanup. Each listing call receives an AbortSignal aborted when its per-engine timeout fires, and concurrent `listSessions` calls for one directory (plus concurrent `listProviders` calls) share one in-flight result that clears on settle; failures are never cached. All engine output (event payloads, detail/command results, list and provider rows, snapshot fields) is redacted in core with `redactAttachmentValues`, so an echoed server-local attachment path never reaches the wire; the registry and router refuse to build without a redactor. Snapshots for engine sessions come from sync `engine.snapshot()` framed with daemon-owned sequence/`lastSequence`/epoch/`sessionId`/`directory` using the snapshot's own absolute directory; a throw logs and writes nothing, and a non-object/undefined/missing-directory result returns nothing, like an unavailable Pi session. Registry creation awaits factories in order (a throwing factory is logged and skipped; a duplicate/invalid id/label/shape disposes already-created engines and throws `INVALID_ENGINE`), ownership-check throws count as not owned, and `stop()` disposes every engine (AggregateError on failure, mirroring the runtime registry) without blocking remaining teardown. Logging records only engine id, short event name, and stable error code, never messages, payloads, credentials, or paths. Extension, resource, settings, and provider-auth commands stay Pi-only. Pending-input contract: engines MAY carry `inputState: { pending }` on list rows, snapshot fields, and detail results, where `pending` is `{ count, kind, since }` (`count` an integer 1..99, `kind` `'input'` or `'approval'` with `'approval'` marking a permission-style request, `since` epoch ms of the oldest open request) or `null` when authoritatively nothing is pending; a missing or malformed value falls back to the daemon index without failing the row. Engines MUST publish `session.input { pending }` on every pending-input transition for live updates: the daemon normalizes the summary, folds it into the pending-input index (hosted requests win when both exist), republishes it canonically as `{ pending, serverNow }`, rejects a malformed summary as `INVALID_ARGUMENT`, and rejects a summary for a session the publishing engine does not own as `INVALID_ARGUMENT` without applying it (a throwing ownership check counts as not owned); an engine `session.deleted` also clears that engine's index state. Engine notifications are live-only: engines publish `extension.notify { message, level }` through the host wrapper; the daemon normalizes the level (`'info'` unless `'warning'`/`'error'`), caps the message at 2000 characters, and publishes through the usual redaction. An empty message is a silent no-op. Nothing is stored, so engine snapshots and details carry no notice history. `session-engine-router.js` owns the dispatch, create targeting, merges, snapshot payloads, publish wrapper, and `engines.list` description behind dependency injection; `session-daemon.js` keeps one-guard call sites.

## Deliberate limits

This module defines the authenticated session API boundary consumed by the Pi-native session UI. Session leases coordinate PiChamber daemons only: the standalone `pi` CLI runs its own foreground runtime and does not honor these leases, so concurrently editing the same session through PiChamber and `pi` remains unsupported until Pi provides compatible upstream locking. Resource configuration and richer attachment extraction remain owned by their dedicated workstreams. The session event stream is SSE over the existing authenticated runtime transport; WebSocket callers fall back to it, preserving direct and relay behavior without a second daemon listener. The public route emits named heartbeat events so native `EventSource` clients can detect a silent foreground connection, reconnect, and resume from their last accepted sequence.

The Pi SDK is pinned exactly at `1.0.0`. Any Pi upgrade requires a deliberate SDK/release-note review and repeat of this module's disposable-runtime smoke validation.

## SDK upgrade validation

The 0.85.1 to 1.0.0 upgrade keeps the public session runtime/services API and the v3 JSONL format. No PiChamber data migration or cache deletion is required. Existing settings, custom model definitions, labels, and extension entries remain intact.

What changed in the SDK and how PiChamber handles it:

- Prompt acceptance. `preflightResult` now receives a disposition (`'started'`, `'queued'`, or `'handled'`) and is not called for a rejected prompt. The daemon treats any callback as acceptance and a rejected prompt promise as rejection. Under 0.85.1 the callback received a boolean, so the old `=== true` check rejected every prompt on 1.0.0.
- Session file timing. The SDK writes the JSONL when the first user message is appended. 0.85.1 waited for the first assistant message. A turn that fails or is aborted after its user message now leaves a persisted session. An untouched session, and a first prompt rejected before the user message, still persist nothing.
- New entry types. `context_edit` and `usage` entries are not transcript rows. History projection reads only `message` entries with the `user` or `assistant` role and the custom entries described above.
- Nested tool calls. Calls made through `ctx.executeTool()` emit `tool_execution_*` events with `parentToolCallId`. The daemon does not publish them, which matches history.
- Model catalog. Remote catalog requests carry `types=chat,image,classifier`. PiChamber lists models through `getModels()` and `getAvailableSnapshot()`, which stay chat-only.
- Theme. The extension theme adds `appearance`, `colors`, and `style()`.
- Built-in extensions. The Pi CLI's list of built-ins is not a public export. `sdk-upgrade.test.js` reads it from the installed package and fails when Pi ships a built-in that `builtin-extensions.js` neither loads nor lists as skipped, so a new upstream built-in needs a decision at upgrade time. It also checks that each loaded entry keeps the CLI's `builtin` and `replaceable` flags.

- `sdk-upgrade.test.js` exercises the installed SDK with disposable directories: cached OpenCode models with provider query parameters (`?types=chat%2Cimage%2Cclassifier`), user model additions and overrides, forced HTTP refresh, ETag/304, failed refresh preserving the last catalog, restart restoration, and opening/appending a session with context edit or usage entries.
- `session-daemon-extensions.e2e.test.js` exercises real extension loading, commands, dialogs, GUI projection, reload, and session disposal.
- `session-daemon-turn.e2e.test.js` runs real agent turns through the installed SDK with an offline provider registered by an extension: prompt acceptance, streamed text, an extension tool with custom renderers, a nested `ctx.executeTool()` call, the session file after the first turn, steer and follow-up queued while streaming, a first prompt rejected with no usable model, and a stored session that holds `context_edit` and `usage` entries.
- `session-daemon-mcp.e2e.test.js` runs real turns against a stdio MCP server the test writes to disk: a `direct` tool row with its rendered call and result, the `/mcp` status notification, a `codemode` script whose row shows the nested call in progress and then the script output with no separate nested row, `tool_search` loading a `deferred` tool that is then called, `-builtin:mcp` disabling the extension and its command, an installed extension that registers `/mcp` replacing the built-in, a server whose command does not exist leaving a normal prompt unaffected, and the server process exiting when the daemon stops.
- `supervisor.test.js` covers authenticated replacement of a daemon from an older PiChamber build. Normal versioned releases must retain that replacement behavior. Same-version, same-path manual dependency replacement is not a release upgrade and requires stopping the old server/daemon before restarting.
- The SDK CLI bin moved to `dist/bundle/cli.js`; the detached daemon already resolves `package.json`'s `bin.pi` rather than hardcoding the old location.
- `pi-runtime-smoke.test.js` checks the pinned SDK and foreground backend in real subprocesses with disposable dirs. It checks the SDK import with pinned version parity, bundled `bin.pi --help`, credential-free `rpc get_state` to success, and a credential-free foreground `serve --foreground --api-only` that polls `/api/pi/runtime` to ready and checks parent and daemon `execPath`. It is not provider E2E. It defaults to `process.execPath` and accepts an absolute override in `PICHAMBER_TEST_RUNTIME`. See `packages/web/bin/lib/DOCUMENTATION.md` for runtime rules and CI matrix.

The bundled OpenCode catalog removes `deepseek-v4-flash-free`. SDK remote catalogs still merge additively with bundled models, so this upgrade fixes that stale entry, not general upstream deletion propagation. Explicit user-defined models with the same ID remain supported. Do not clear users' `models.json` or `models-store.json` to apply an upgrade.
