fix(agent): align facet service contracts and errors

This commit is contained in:
Armin Ronacher 2026-08-26 16:02:18 +02:00
parent 8288bc3543
commit 6441fa68b5
11 changed files with 473 additions and 953 deletions

View file

@ -42,9 +42,9 @@ agent.subscribe((event) => {
await agent.prompt("Hello!");
```
## Experimental plugin services
## Experimental facet services
The package exports the transport-neutral plugin-service primitives used by the experimental application hosts: `defineService()`, `replicatedState()`, `RemoteServiceProvider`, and `RemoteServiceNamespace`. Providers classify implementation methods and replicated-state members at runtime; consumers receive stable singleton facades through `use()` or lifecycle-scoped keyed instances through `observe()`. `ReplicatedState` values are borrowed immutable JSON: the runtime does not defensively clone them, so callers must not mutate or retain them. Wire framing and host routing remain outside this package.
The package exports the transport-neutral facet-service primitives used by the experimental application hosts: `defineService()`, `replicatedState()`, `RemoteServiceProvider`, and `RemoteServiceNamespace`. Providers classify implementation methods and replicated-state members at runtime; consumers receive stable singleton facades through `use()` or lifecycle-scoped keyed instances through `observe()`. `ReplicatedState` values are borrowed immutable JSON: the runtime does not defensively clone them, so callers must not mutate or retain them. Wire framing and host routing remain outside this package.
## Core Concepts

View file

@ -1,301 +0,0 @@
# Plugin Reloading
> **Status:** Design input for the plugin architecture in `plugins.md`. This document defines the architectural reload model, not an implementation handoff. Exact control messages, persisted file formats, retry timing, and diagnostics remain implementation details.
Read `plugins.md` for host facets and service contracts, `rpc.md` for binding and subscription semantics, and `harness.md` for the durable Session and execution model. This document and `plugins.md` supersede the service-dependency, reload, and rollback discussion in the exploratory `extensions/pi-extensions-v2.md` where they conflict.
## Decision
A source reload replaces the affected facet environments while preserving service facades held by unaffected consumers. When a replacement facet declares the same requirements, provisions, and modes, the host stages it, temporarily disconnects its singleton provisions, deactivates the old facet, activates the replacement, rebinds those provisions, and only then unloads the old module generation. A singleton returned earlier by `use()` retains its identity and resolves the replacement implementation.
One reload may target changed facets in:
- the server host;
- every live session worker;
- every connected TUI or web host; and
- the facet sources used by hosts started after the reload.
A structural change to requirements, provisions, modes, or manifest membership requires graph reassembly rather than an in-place facet swap. Replacing Session ownership or other process authority may still require an ordinary worker or host restart. Hosts do not switch at one atomic instant: the server records one desired source generation and every host converges on it, so plugin contracts remain forward compatible across the skew window.
Shape-preserving reload uses ordinary facet deactivation and activation; structural or authority replacement uses ordinary host shutdown and startup. Neither has a stronger safety or rollback guarantee, and arbitrary in-process work is not assumed safe to restart.
## Terms
These operations are different:
- **Rebuild** replays active contributions over a fresh draft and commits a new registry value. Settings, configuration, availability, or source data changed, but plugin module bytes did not.
- **Reload** selects new source for changed facets. Shape-preserving facets swap in place; structural graph or process-authority changes require broader host reassembly or restart.
- **Restart** stops and recreates a concrete server, session worker, or presentation runtime while implementing a reload.
- **Rebind** moves connected services from one live provider binding to another and hydrates fresh snapshots.
- **Resume** continues durable Harness work from the Session repository after a worker restart.
Most runtime changes should be rebuilds. Only changed source bytes, changed plugin resolution, or changed manifest membership require reload.
## Durability boundary
Pi has a stronger durability boundary than an in-process plugin framework. The authoritative Session, accepted operations, invocation memos, plugin-owned bound values and lists, and other committed Session records survive the process that currently projects them.
A reload divides state into three layers:
1. **Durable authority** survives reload. It includes Session records, durable Harness control, accepted operation IDs, plugin-owned durable records, and persisted server intentions.
2. **Affected runtime state** is reconstructed. It includes replaced facet environments, contributions, listeners, timers, caches, open files, and subprocess handles; broader restart also reconstructs Harness instances and host infrastructure.
3. **Affected presentation replicas** are rebound. Shape-preserving singleton replacement keeps member facades while installing a complete snapshot; route or worker replacement also rebuilds keyed observer tasks, widgets, and attachment routes.
Reload never reverses committed durable state. Cordis-style inverse tracking is useful for orderly release of process-local resources, but it is not a transaction over Session history, filesystem emissions, subprocess effects, or network effects. A plugin that needs durable recovery must express it through the Session durability model rather than rely on a disposer.
A removed plugin's durable state is retained under its stable plugin namespace. Removal stops its code and projections; it does not erase its records. The plugin may return in a later manifest generation.
Durable schemas and resumable behavior are the plugin's compatibility responsibility. An operation accepted under generation N may resume under generation N+1. Reload introduces no new migration or version-pinning mechanism beyond that existing durability requirement.
## User-visible operation
For now, reload is explicitly initiated by the user through `/reload`, as in the current coding agent. The command belongs to host control, outside the plugin runtime being replaced.
The command completes when the system has accepted the reload and begun moving to the new desired generation. It does not wait for every host to become usable again. Normal application interaction may suspend while the Harness, services, and presentation are unavailable, but that suspension is host behavior rather than the lifetime of the command invocation.
A reload cannot be cancelled. A later implementation may report progress for each host, but cancellation would create another partially coordinated generation and is not part of the model.
`/reload` must not be implemented as a session plugin service method. Otherwise the retiring service graph would be asked to destroy itself while its own invocation remained active. A minimal reload and failure-reporting path must remain available even when plugin facets fail to load.
## Manifest generations
The server owns the desired manifest generation. The server directory contains the persistent server state needed to recover after server replacement, including:
- the desired manifest generation and resolved plugin identity;
- the sessions whose workers the server intends to keep connected or reconstruct; and
- reload failure information needed to explain a degraded host after restart.
The exact file layout is not part of this design. Transient request IDs, subscription IDs, attachment IDs, and process IDs are not durable plugin state.
One generation names one resolved manifest across all facet kinds. Hosts resolve only their own entry points, but they report and converge on the same generation. A newly opened session starts directly on the desired generation. A live host may converge by swapping affected facets in place, structurally reassembling its graph, or restarting when process authority requires it.
Temporary cross-generation communication is expected. The system does not attempt a distributed lock-step commit. Shared service contracts, durable records, and control messages must tolerate the supported forward-compatibility window. Runtime member-kind validation remains useful but does not replace contract compatibility.
Each process receives `FacetLoader` instances configured for its process kind and independently disposable source bundles. Calling `load()` returns an ordered facet set plus a disposer for that loaded module or sandbox generation. The current loader may return hardcoded built-ins; a manifest-backed loader resolves the same `./server`, `./session`, `./tui`, or `./web` entry points. For a shape-preserving reload, the coordinator loads the replacement bundle, asks the existing host to reload its matching facet IDs, then disposes the retired bundle. A combined loader can still assemble initial startup or a structural replacement. The loader collects code; facet setup collects service requirements and providers.
## Dependency discovery and manifest validation
The service dependency graph is generated from facet setup, not reflected from erased TypeScript interfaces and not maintained as a parallel handwritten list. Every service token retains a stable runtime ID. Each host adds a runtime facet that provides its concrete local services through the ordinary facet environment. Setup runs synchronously as a declaration phase; service handles remain disconnected until the complete graph validates.
During setup the host records:
```text
provide(service, implementation) → this facet provides service/singleton
provideMany(service) → this facet provides service/keyed
use(service) → this facet requires service/singleton
observe(service, handler) → this facet requires service/keyed
```
The process kind selects the facet lifetime and host capabilities. A facet always uses unqualified `env.use()` and `env.observe()`; the host routes tokens across local, server, and selected-Session services without exposing namespaces in the facet API. The token supplies the service ID, and the operation supplies singleton or keyed mode. No runtime representation of the generic service interface is needed.
First acquisition and provision are setup-only operations. Commands, hooks, event handlers, and activation callbacks use handles acquired during setup. `provideMany()` returns the only `ServiceInstances` handle permitted to add that service's dynamic instances, allowing the provision to appear in the graph before any instance exists.
After setup, the host privately resolves its recorded requirements and provisions. Connections return provider-generated catalogues containing service IDs and modes; the host matches unresolved requirements to exactly one connection, opens generation-owned bindings only for selected services, and rejects missing providers, duplicate offers, mode mismatches, and invalid dependency cycles. The record is kernel machinery, not a plugin-facing plan or a second declaration format. Transport bindings must become ready before consuming facets activate. `use()` and `observe()` are hard requirements; optional dependencies need a separate explicit acquisition operation if introduced later. Directly importing another plugin's live implementation bypasses this ownership model and is unsupported.
Removing one plugin constructs a candidate graph without that plugin. If retained facets have unresolved hard requirements, the candidate is rejected before becoming desired. Valid removal is structural: the host reassembles the affected graph and disconnects removed services while retaining the plugin's durable namespace.
The service graph does not replace the module loader's source import graph. A facet may swap in place only when its source bundle can be unloaded independently and its runtime service shape is unchanged. If another active facet imports changed module bytes, that facet is affected too. The reload coordinator therefore combines source ownership with the generated service graph rather than treating either graph as sufficient by itself.
## Reload sequence
At the architectural level, a reload proceeds as follows:
```text
user invokes /reload
→ server records desired source generation N+1
→ each host loads and validates its affected replacement facets
→ shape-preserving facets disconnect old provisions and deactivate in dependency order
→ replacements activate and stable local facades resolve the new implementations
→ stable RPC singleton facades install complete replacement snapshots
→ structural or authority changes restart the affected host or Session worker
→ normal application interaction resumes where required services are ready
```
Module resolution, evaluation, facet setup, and unchanged service shape are checked before deactivating the old facets where possible. Such preflight reduces avoidable failures but does not make reload transactional. Deactivation or replacement activation can still fail after the old facet has stopped.
All facet-owned resources close through the ordinary facet environment before graceful process exit. If graceful shutdown fails, the host may terminate the process according to its normal shutdown policy. The operating system then reclaims process resources, but it cannot undo external emissions or make an unsafe tool restartable.
## Session-worker replacement
Exactly one process owns an open Session and Harness at a time. The Session repository and the worker lock enforce that rule. Session reload therefore is a handoff, not blue/green activation:
```text
old worker stops accepting work
→ ordinary Harness/worker shutdown runs
→ old worker releases Session ownership
→ replacement worker opens the same Session
→ replacement constructs the Harness and complete service provider
→ presentations receive fresh routes and snapshots
```
There is intentionally an interval with no authoritative worker. The server retains the logical fact that a presentation selected a Session even while no live route exists.
Reload does not wait for a special reload-safe Harness state. It behaves like stopping and restarting the Harness. Durable state provides valid reconstruction where the Harness and plugin contracts support it. Process-local or externally executing effects may fail:
- a running shell command is not generally restartable;
- a tool may not have a safe replay path;
- a provider request may be interrupted;
- a plugin-owned subprocess may be terminated; and
- an invocation may have committed durable state while losing its response.
These are ordinary shutdown hazards. Reload does not claim to repair them. Tools and plugins that support durable resumption must use the existing Harness mechanisms; those that do not may fail when the user reloads at an unsafe time.
## Calls during replacement
Reload adds no RPC transaction or automatic replay.
When a providing facet or host begins replacement:
- affected services become unavailable through their stable facades and do not queue new work;
- active invocation contexts, watches, subscriptions, and observations owned by the retiring lifecycle close normally;
- interrupted calls fail with cancellation, disconnect, reloading, or unavailable behavior appropriate to the boundary;
- calls are never queued merely because a replacement is starting; and
- clients never blindly replay a mutation whose outcome is uncertain.
A call may have committed durable state before its response was lost. The caller must reconnect, hydrate authoritative state, and reconcile through a stable operation ID or an idempotent application method where the contract provides one. This is the same rule as an uncertain transport disconnect.
Service-owned work remains distinct from an invocation. If it is durable, the replacement reconstructs it from durable records. If it is process-local, ordinary shutdown may abort it. Closing an RPC wait must not silently write durable cancellation.
## Attachment replacement
A presentation's logical Session selection and its live route are separate state:
```text
selected Session S + live attachment A
selected Session S + no live route while reloading
selected Session S + replacement attachment B
```
A replacement worker receives fresh attachment IDs. Attachment IDs are transport-internal, so preserving one across worker generations has no plugin-level benefit. Fresh IDs reuse the existing stale-route fence: delayed calls and frames for A cannot address B.
`SessionServiceConnection.attachment` continues to use the existing presentation-safe states:
```ts
type AttachmentState =
| { status: "detached" }
| { status: "attaching" | "attached" | "degraded"; sessionId: string };
```
During replacement the selected Session is `attaching`. Successful route acquisition moves it to `attached`; replacement failure moves it to `degraded`. `detached` remains an intentional absence of selection, not a synonym for worker restart.
The global suspended reload experience is separate from attachment state. Attachment state reports only whether the selected Session is currently routable.
## Service lifetime
A service facade belongs to its consumer, independently of the current providing facet environment. Shape-preserving provider reload swaps the implementation behind that facade.
The implementation follows these rules:
- `RemoteServiceProvider.provide()` installs the initial singleton, `withdraw()` reports its temporary unavailability, and `replace()` publishes a complete singleton replacement snapshot to existing subscriptions;
- local singleton proxies dispatch captured method functions through the current implementation slot rather than closing over the original method;
- RPC member facades retain identity while replacement state hydrates from the complete snapshot;
- plugin-facing `provideMany()` registers keyed ownership during setup, while its `ServiceInstances.add()` handle preserves generation fencing when a key is reused;
- a server connection or Session attachment change still closes route-bound subscriptions and hydrates the selected provider; and
- binding revisions and attachment IDs fence delayed frames.
Changing service tokens or modes is structural. The host must reassemble catalogues and dependency bindings before switching that graph. During temporary version skew, a service absent from one side fails normally; forward-compatible plugin bundles must not assume every host has switched already.
Captured implementation objects from a retired provider are stale and must not be retained. Consumers retain only the facade returned by `use()` or member facades obtained from it.
## Current core services
The experimental core services already illustrate the reconstruction rules:
| Service | Current owner | Reconstruction after reload |
|---|---|---|
| `SessionDirectory` | Server, with one provider per presentation over shared repository-backed state | List the repository again and publish a complete directory snapshot. |
| `SessionManagement` | Server provider closure bound to one presentation | Recreate the per-presentation implementation after reconnect. In-flight management calls follow ordinary uncertain-disconnect semantics. |
| `Models` | Session worker over Harness and `ModelRuntime` | Read selected model and thinking level from the Harness and rebuild the catalog from the model runtime. Process-local refresh status is discarded. |
| `Chat` | Session worker over the Harness | Durable accepted work remains in the Session. A call interrupted before receiving its operation ID has an uncertain outcome and must not be blindly retried. |
| `Accounts` | Session worker facade over credential authority | Rebuild presentation-safe account summaries from the credential source. Credentials never live in `ReplicatedState`. |
| `Transcript` | Session worker projection of durable lane state | Implement an authoritative revisioned snapshot before removing the compatibility watch. |
`Accounts` and `Transcript` are currently incomplete scaffolds, but their reload ownership follows the same rule.
Provider-local counters are not automatically durable. `ReplicatedState` transport sequences, keyed generations, model-catalog revisions, and directory revisions may restart with a new provider binding unless their application contract explicitly makes them durable. Consumers must not compare binding-scoped revisions across a rebind as if they were globally monotonic.
## Replicated state and keyed services
`ReplicatedState` is a projection, not storage. Every state needed after reload must be reconstructible from durable authority, configuration, or another owned source. On provider replacement, the consumer becomes unready and installs a fresh complete snapshot before later updates.
A consumer that must recover after reload needs a `ReplicatedState` snapshot or a pull method returning current authority.
Keyed services are live projections. If an instance must return after worker replacement, its owner reconstructs it from durable invocation or plugin records. The new provider may reuse the logical key, but it creates a fresh live generation under the new attachment binding. A stale proxy never locates durable work by key alone.
## Contribution registries
Contribution registries are rebuilt, not individually undone. After affected facets swap, the host collects the complete current contribution set, replays it over fresh drafts, finalizes each registry, and publishes the resulting state.
This is preferable to asking plugins to reverse mutations to a shared tool, provider, command, or renderer registry. Lifecycle disposal still closes process-local effects, but registry correctness comes from reconstruction.
A configuration or settings change that leaves module bytes and manifest membership unchanged should rebuild the affected registries in the current generation rather than trigger `/reload`.
## Failure semantics
Reload has no runtime rollback guarantee.
If a facet at source generation N stops and its N+1 replacement fails, the affected services or host remain unavailable or degraded. The system records and reports the failure. It does not silently restore N. The user fixes the plugin or configuration and invokes reload or restart again.
Durable records remain as committed. Plugin state remains retained. Other sessions or hosts that already reached N+1 remain there; the desired generation does not move backward merely because one host failed.
The following claims are explicitly out of scope:
- atomic reload across processes;
- safe reload detection;
- rollback of durable writes or external effects;
- preservation of arbitrary plugin-local memory;
- transparent replay of interrupted RPC methods;
- changing a facet's service contract in place without graph reassembly; and
- automatic durable schema migration.
## Relationship to Cordis
Cordis identifies two useful concerns: resources created by a component need lifecycle ownership, and consumers must respond when a provider disappears. Pi keeps both principles:
- facet environments own registrations, listeners, subscriptions, and process-local effects; and
- connected services become unavailable and rehydrate against replacement providers.
Pi uses in-process facet replacement for shape-preserving source changes, while durable Session authority, process-per-session isolation, and server replacement still require broader lifecycle boundaries when ownership or graph structure changes. Inverse disposal remains cleanup, not rollback.
This also follows Cordis's system-boundary caveat: acquiring and releasing a process resource may be reversible, while bytes already written, commands already executed, and durable records already committed are not.
## Architectural footguns
- Implementing `/reload` inside the plugin or service runtime it destroys.
- Reloading one facet while another active facet still imports the same changed source bytes.
- Opening the same Session in old and replacement workers concurrently.
- Treating `ReplicatedState` as the durable source rather than a reconstructible projection.
- Reusing an attachment ID across worker generations and weakening stale-route fencing.
- Automatically retrying a call after reload when its durable outcome is uncertain.
- Deleting namespaced durable data when a plugin leaves the manifest.
- Treating provider-local sequence or revision values as global across bindings.
- Retaining implementation objects, callbacks, or contexts from a retired generation in host-global state.
- Performing uncontrolled top-level module side effects before the host owns a facet environment.
- Acquiring a service for the first time from a later command or callback, after dependency assembly has closed.
- Dynamically adding a service instance without first registering its owner through `provideMany()`.
- Importing another plugin's live implementation instead of depending on a host-mediated service.
- Describing preflight checks as an atomic or rollback-capable reload.
## Required tests
A later implementation handoff should cover at least:
- one `/reload` selects one desired source generation for affected server, session, and presentation facets;
- independently loaded facet bundles dispose only after their active facets retire;
- retained local and RPC consumers keep the same singleton facade across provider-facet replacement and RPC state installs a complete replacement snapshot;
- setup-time service acquisition produces the complete internal dependency graph without reflecting on TypeScript types;
- missing providers, duplicate singleton providers, mode mismatches, and late dependency acquisition are rejected;
- removing a plugin rejects an invalid dependent graph or structurally reassembles the host with fewer facets while retaining its durable state;
- configuration-only changes rebuild without loading a new module generation;
- a worker releases Session ownership before its replacement opens the Session;
- reload during active non-restartable work follows ordinary shutdown behavior and makes no false recovery guarantee;
- interrupted calls are not automatically replayed;
- a committed durable operation remains discoverable after its initiating response is lost;
- logical Session selection survives the route gap, reports `attaching` or `degraded`, and receives a fresh attachment ID on success;
- old attachment calls, service updates, and keyed proxies are rejected after replacement;
- singleton state rehydrates from a complete new snapshot before updates flow;
- durable keyed interactions reconstruct from records with fresh live generations;
- removed plugin data remains stored and is readable when the plugin returns;
- server replacement reads desired manifest and worker intentions from the server directory;
- a failed replacement remains degraded and reports diagnostics without rolling back; and
- temporary old/new facet skew exercises forward-compatible contracts rather than assuming lock-step rollout.

File diff suppressed because it is too large Load diff

View file

@ -1,10 +1,10 @@
# Plugin Service RPC Design Notes
# Facet Service RPC
> **Status:** Design input, not a normative contract or implementation handoff. This document specifies only the RPC semantics required by `plugins.md`. Exact control-frame shapes and local proxy implementation are intentionally deferred.
> **Status:** Design specification for experimental facet-service RPC semantics.
## Role
`provide()`/`use()` and `provideMany().add()`/`observe()` are the plugin system's hidden RPC. Plugin facets share TypeScript service contracts, while the transport carries only service/member identifiers, strict JSON values, request/subscription correlation, and trace/control metadata. Hosts construct the typed local implementation or facade; TypeScript types and arbitrary objects never cross the wire. Connected hosts may temporarily run different manifest generations, so their service contracts must remain forward compatible across the supported skew window; version negotiation remains deferred.
`provide()`/`use()` and `provideMany().add()`/`observe()` are the facet system's hidden RPC. Facets share TypeScript service contracts, while the transport carries service/member identifiers, strict JSON values, request/subscription correlation, keyed generations, and binding control messages. Hosts construct the typed local implementation or facade; TypeScript types and arbitrary objects never cross the wire. Independently loaded processes may temporarily run different source generations, so their service contracts must remain forward compatible across the supported skew window; version negotiation remains deferred.
```text
session/server facet: provide() / provideMany().add()
@ -12,7 +12,7 @@ session/server facet: provide() / provideMany().add()
presentation/session facet: use() / observe()
```
The service system is the plugin boundary. Presentation facets receive semantic services and replicated state; they never receive a raw Harness, Session, tool registry, hook registry, credential store, or storage handle.
The service system is the extension boundary. Presentation facets receive semantic services and replicated state; they never receive a raw Harness, Session, tool registry, hook registry, credential store, or storage handle.
## Non-goals
@ -22,11 +22,11 @@ Do not serialize `Context`, `AbortSignal`, telemetry objects, callbacks, tools,
A service token is a shared TypeScript contract and stable service ID. It is not a generated descriptor and creates no provider. Tokens are RPC-capable by default; process-local tokens declare `{ rpc: false }`. `provide()` adds one singleton implementation to the host graph. `provideMany()` registers one keyed-service owner during facet setup and returns a `ServiceInstances` handle whose later `add()` calls add instances. The host automatically publishes every provided RPC-capable service. A token has one mode in one host service graph: mixing singleton and keyed use is an error.
The provider must make enough control-plane information available for the host to validate an accessed member as a method or `ReplicatedState`. The consumer must be able to obtain the member name from ordinary property access—for example, a JavaScript `Proxy` receives `"state"` for `models.state` and `"refresh"` for `models.refresh(context)`. How a disconnected lazy proxy represents an unresolved member, and the exact provider-kind validation exchange, are implementation details.
The provider classifies every exposed implementation member as a method or branded `ReplicatedState` and publishes that member table in subscription snapshots. The consumer obtains member names from ordinary property access—for example, a JavaScript `Proxy` receives `"state"` for `models.state` and `"refresh"` for `models.refresh(context)`. Accessed slots are validated against the provider-announced kind when the facade binds.
Local and remote `use()` both return a stable, lazy typed facade shared by consumers of that token. During synchronous facet setup the facade is disconnected, so setup can capture it but cannot invoke methods, read state, or register member subscriptions. After assembly, a local facade resolves to its facet-provided implementation and a remote facade binds through the host's connected services. Reloading the providing facet temporarily marks that same facade unavailable, then swaps its target; RPC singletons clear readiness and install a complete replacement snapshot on their existing subscription so captured methods and member facades address the replacement. While no provider is bound, invoking a method fails and state remains unhydrated; no call is queued merely because it was made through a proxy.
Remote methods return promises and accept and return strict JSON apart from their declared `Context`; `void` is a successful response without a result field. A private returned reference is a separately validated control envelope, not a business JSON value. The client removes the context before transport and the receiving host constructs a fresh local context. The contract position is host-controlled and must be consistent; the current examples use one required trailing `Context`. Business absence is JSON `null` or an options object, never transported `undefined`.
Remote methods return promises and accept and return strict JSON apart from their declared `Context`; `void` is a successful response without a result field. Private returned references are not supported. The client removes the context before transport and the receiving host constructs a fresh local context. The contract position is host-controlled and must be consistent; the examples use one required trailing `Context`. Business absence is JSON `null` or an options object, never transported `undefined`.
Use static assertions and runtime validation. Static checks constrain remote methods and replicated-state members; runtime boundaries reject unsupported members and non-JSON arguments, results, and state values. TypeScript supplies typed facades but does not authenticate a peer or create runtime metadata.
@ -43,11 +43,11 @@ Facets always call unqualified `env.use()` or `env.observe()`; routing is not en
First acquisition or provision is permitted only during facet setup. Later commands, hooks, and activation callbacks use setup-acquired singleton facades, observer registrations, or `ServiceInstances` handles. In particular, dynamic instances are added through the handle returned by `provideMany()`; a late direct addition cannot introduce a previously undeclared provision.
After setup, each host privately resolves the recorded requirements against local provisions and connection catalogues to reject missing providers, duplicate remote offers, and mode mismatches and to derive lifecycle edges. A selected-Session connection with no live attachment may provisionally accept unresolved requirements as unavailable; attachment validates them against the worker's generated catalogue and caches that catalogue for later detached generations. Connection bindings are generation-owned and include only selected requirements, so failed or retired generations release their subscriptions without disposing the underlying transport connection. This internal service graph is distinct from the module loader's source import graph; it is not a plugin-authored or plugin-visible plan.
After setup, each host privately resolves the recorded requirements against local provisions and connection catalogues to reject missing providers, duplicate remote offers, and mode mismatches and to derive lifecycle edges. A selected-Session connection with no live attachment may provisionally accept unresolved requirements as unavailable; attachment validates them against the worker's generated catalogue and caches that catalogue for later detached generations. Connection bindings are generation-owned and include only selected requirements, so failed or retired generations release their subscriptions without disposing the underlying transport connection. This internal service graph is distinct from the module loader's source import graph; it is not a facet-authored or facet-visible plan.
## Bindings and identity
A presentation host combines services from its connected server and selected Session in one graph. All of its facets use the same unqualified environment API. A Session-service call never accepts a client-selected durable `sessionId`; the server authorizes attachment and routes the selected Session binding to its worker.
A presentation host combines services from its connected server and selected Session in one graph. All of its facets use the same unqualified environment API. A Session-service call never accepts a client-selected durable `sessionId`; the server authorizes and routes the presentation's selected Session binding to its worker.
### Server control plane
@ -58,11 +58,11 @@ const directory = env.use(SessionDirectory);
const management = env.use(SessionManagement);
```
The server derives workspace and client authority from its locally authenticated identity, not from the summary or method arguments. It may project directory state per client; either way, summaries never expose server-private fields such as owner IDs or working directories.
The server binds one service provider to each presentation connection. It derives workspace and client authority from locally authenticated connection identity, not from summary fields or method arguments. It may project directory state per client; either way, summaries never expose server-private fields such as owner IDs or working directories.
`management.attach(sessionId, context)` changes the selected-Session services in the presentation host. The server authorizes the requested managed Session, closes the presentation's previous Session-scoped requests, subscriptions, references, and observer tasks, binds the presentation's Session services to the worker, then hydrates their singleton state and keyed-instance directory. Attachment state is host control state reporting this selection and its health; it is not a directory service. `detach()` performs the same cleanup without a replacement.
`management.attach(sessionId, context)` changes the selected-Session services in the presentation host. The server closes the presentation's previous Session-scoped requests, subscriptions, and observer tasks, binds the presentation's Session services to the worker, then hydrates their singleton state and keyed-instance directory. The server authorizes the selected Session against the connection identity. Attachment state is host control state reporting this selection and its health; it is not a directory service. `detach()` performs the same cleanup without a replacement.
The host needs a private, host-owned binding incarnation for that route. It changes when the presentation attaches, detaches, switches session, or replaces a failed worker. Its representation is deliberately unspecified. The binding prevents a delayed frame for the old selected session from being applied to the new one; it is not a plugin-visible service value or a substitute for authorization.
The host needs a private, host-owned binding incarnation for that route. It changes when the presentation attaches, detaches, switches session, or replaces a failed worker. Its representation is deliberately unspecified. The binding prevents a delayed frame for the old selected session from being applied to the new one; it is not a facet-visible service value or a substitute for authorization.
A replicated-state source has structural identity:
@ -70,11 +70,11 @@ A replicated-state source has structural identity:
(provider binding, service ID, optional instance key + generation, member name)
```
There is no separately discoverable state ID. An added instance key is a plugin-level logical key. Its host-owned generation changes when a closed key is reused, so a stale proxy cannot call the replacement. `requestId` identifies one transport invocation for response, cancellation, and tracing. A Harness/tool `invocationId` may be a useful instance key—as in the question plugin—but it does not replace the service, binding, or generation parts of the live address.
There is no separately discoverable state ID. An added instance key is an application-level logical key. Its host-owned generation changes when a closed key is reused, so a stale proxy cannot call the replacement. `requestId` identifies one transport invocation for response and cancellation. A Harness/tool `invocationId` may be a useful instance key—as in the question example—but it does not replace the service, binding, or generation parts of the live address.
## Calls, context, and routing
A call carries enough control-plane information to select a provider binding, service, optional keyed instance, and member, plus a request ID, JSON arguments, and trace carrier. The server may parse those control-plane fields to route a session call, but it does not parse plugin business payloads or load plugin contracts. The service endpoint validates the member and values, creates a request-local abort controller and `Context`, installs authenticated client identity as a server-created context value, and invokes the local implementation.
A call carries enough control-plane information to select a provider binding, service, optional keyed instance, and member, plus a request ID, JSON arguments, and trace carrier. The server may parse those control-plane fields to route a Session call, but it does not parse facet business payloads or load facet contracts. The service endpoint validates the member and values, creates a request-local abort controller and `Context`, installs authenticated identity, and invokes the local implementation.
The client maps `context.abortSignal` to cancellation of that one request. Disconnect cancels that connection's active calls and closes its subscriptions. Neither action cancels service-owned work or writes durable Harness cancellation. Per-client request correlation reaches the worker so request IDs from different presentations cannot collide.
@ -82,19 +82,21 @@ The client maps `context.abortSignal` to cancellation of that one request. Disco
`ReplicatedState` is authoritative latest-value replication, not event history, durable storage, a CRDT, or multi-writer state. A cold replica has `value === undefined`; subscribing before hydration records a listener without invoking it. Hydration installs a complete snapshot atomically before later updates are delivered, so there is no snapshot/update gap. Once hydrated, `value` is synchronous and subscribing reports the current value followed by later updates. The first snapshot callback uses a fresh hydration context; an already hydrated replica uses a fresh local delivery context rather than retaining the original write context. State values are borrowed immutable JSON and are not defensively cloned; callers must not mutate or retain them.
The providing host attaches source trace metadata to updates. The consumer reconstructs fresh delivery contexts; hydration uses a fresh context parented to the subscription. Disconnect may retain a value as stale display data. Reconnect replaces it from a complete snapshot, while a switch to a different provider clears readiness and disposes binding-specific resources. The precise subscription, buffering, sequence, acknowledgement, and flow-control frames are transport mechanics.
Remote hydration uses a fresh delivery context parented to the subscription, while updates reconstruct fresh delivery contexts from source trace metadata. Disconnect, provider withdrawal, replacement, and route switching clear readiness. Reconnect or replacement installs a complete snapshot before later updates. The transport buffers updates that race hydration and checks their sequence. Acknowledgements, flow control, and gap recovery remain separate protocol mechanics.
`observe()` is keyed-instance discovery, not a `ReplicatedState` containing proxies. It reconciles a complete initial directory with ordered additions, replacements, and removals. Each instance's initial state members hydrate before its observer task starts. Closing an instance rejects new calls, aborts only that instance's observer task, and allows admitted calls to settle. A Session facet's `env.observe()` registration aborts old tasks when that facet generation closes; the replacement generation reconciles the fresh directory.
## Private returned references
A service method may return a private remote reference for caller-owned work, such as `IndexJob`. This is distinct from a keyed service: a reference is passed explicitly and is not discovered by `observe()`. It is scoped to the recipient and its provider/binding lifetime, and must be invalidated on the relevant close, switch, or disconnect. Exact reference encoding, ownership, and collection remain open.
Private returned references are outside the initial service contract. Prefer keyed services for discoverable live instances. If a concrete feature requires caller-private remote identity, its reference must be passed explicitly rather than discovered by `observe()` and scoped to the recipient and provider binding.
No generic Harness projection is part of this design. Raw Harness, Session, lane, tool, hook, and storage objects remain local authority. If a future integration needs a remote callback or a general object capability, it needs a separate explicit protocol and policy; it is not an extension of service RPC.
## Context, cancellation, and telemetry
Every remote method receives a fresh local `Context`; the sender's object, signal, telemetry implementation, and arbitrary typed values never cross the wire. The client starts `rpc.client`, injects its trace carrier, and maps the call's abort signal to that request. The endpoint extracts the carrier, starts `rpc.server`, and constructs a request-local abort signal and server-created typed values such as authenticated client identity.
Every remote method receives a fresh local `Context`; the sender's object, signal, telemetry implementation, and arbitrary typed values never cross the wire. The client maps the call's abort signal to that request and injects a trace carrier. The endpoint constructs a request-local abort signal and telemetry parent. Cancellation is forwarded through the server to Session workers and remains isolated by client plus request ID.
The span relationship is:
```text
caller
@ -103,33 +105,33 @@ caller
└─ service implementation
```
State delivery carries source trace metadata. The consumer reconstructs a delivery context from it rather than retaining the context that established the subscription.
Three cancellation domains remain separate: aborting one RPC invocation; explicitly cancelling service-owned work such as `job.cancel()`; and durable Harness cancellation such as `requestAbort()`. Transport cancellation and disconnect perform only the first. Work that outlives a call must detach into a service-owned task with its own controller and telemetry root.
## Security and lifecycle
Only loaded service tokens marked RPC-capable may be registered at the remote boundary. Only the owning `ServiceInstances` handle may add instances. Services marked `{ rpc: false }` are never discoverable remotely. Providers validate member kinds and every JSON business value; clients cannot forge control envelopes as ordinary values, choose instance generations, select another session's route, choose server context values, or cancel another client's request.
Only loaded service tokens marked RPC-capable may be registered at the remote boundary. Only the owning `ServiceInstances` handle may add instances. Services marked `{ rpc: false }` are never discoverable remotely. Providers validate member kinds and every JSON business value; clients cannot forge control envelopes as ordinary values, choose instance generations, select a different Session route in a service call, or cancel another client's request.
The server authenticates its connection and authorizes attachment. It reconstructs client identity locally at the service endpoint; ordinary arguments never carry authority. Credentials, prompts, completions, tool data, filesystem contents, and other sensitive values cross only through an explicit presentation-safe contract.
The server authenticates connections, authorizes attachment, and reconstructs client identity in the service `Context`. Ordinary business arguments never carry authority. Credentials, prompts, completions, tool data, filesystem contents, and other sensitive values require an explicit presentation-safe contract.
Facet environments own registrations, added service instances, subscriptions, observations, in-flight RPCs, and presentation resources. Disposal and binding changes close the relevant resources in dependency order. The provider's own Session work remains alive unless its own lifecycle policy stops it.
Facet environments own registrations, added service instances, observations, and resources explicitly registered through `own()`. Connection bindings own their transport subscriptions and active request controllers. Already-admitted inbound calls are not attached to the providing facet lifecycle: provider withdrawal rejects new calls, but an admitted method may continue while the old facet deactivates. The provider's own Session work remains alive unless its lifecycle policy stops it.
## Required tests
## Tests
Test the plugin-facing semantics over loopback and a real framed transport:
The facet-facing semantics are tested over loopback and framed transports:
- setup-time dependency-ledger ownership, rejection of late acquisition, local and remote `use()`, keyed-provider ownership, singleton/keyed mode validation, token-driven RPC publication, lazy member access, stable local and RPC singleton facades across provider-facet replacement, and `{ rpc: false }` services remaining unreachable remotely;
- strict JSON boundaries, method context reconstruction, request cancellation isolation, and trace propagation without serializing context values;
- server/Session facet isolation, authorized attach, selected-Session switching, stale-frame rejection, and worker-side per-client request correlation;
- cold and hydrated `ReplicatedState`, snapshot/update race freedom, delivery contexts, stale display on reconnect, and clearing on provider switch;
- instance directory hydration, ordered reconciliation, state hydration before observer tasks, generation-based stale rejection, and task cleanup on close or switch;
- private returned-reference lifetime distinct from keyed-service discovery; and
- the question and shared-review patterns: concurrent instances, late presentation attachment, disconnect without cancelling session work, worker replacement, and durable application-level settlement.
- strict JSON boundaries, method context reconstruction, and request cancellation isolation without serializing context values;
- server/Session facet isolation, selected-Session switching, stale-frame rejection, and worker-side per-client request correlation;
- cold and hydrated `ReplicatedState`, snapshot/update race freedom, fresh delivery contexts, and clearing/re-hydration on disconnect, reconnect, and provider replacement;
- instance directory hydration, ordered reconciliation, state hydration before observer tasks, generation-based stale rejection, and task cleanup on close or switch.
Additional tests cover authenticated attachment and identity, telemetry propagation, flow control and gap recovery, plus the question and shared-review application patterns. If private references are added, they require separate lifetime and isolation coverage.
## Open protocol mechanics
The following are intentionally not specified here: most exact control-frame schemas; member-kind discovery and lazy-proxy implementation; subscription sequencing, acknowledgements, buffering, and flow control; reference collection; and how a future multi-pane presentation represents more than one selected session. Singleton provider replacement requires a complete replacement snapshot on the existing subscription so method and state member slots retain identity. The remaining mechanics must preserve the semantics above without changing the plugin contract.
Exact service call, cancellation, subscription, snapshot/update, keyed-instance, unavailable, and replacement frames are defined in `packages/protocol/src/protocol.ts`. Provider and namespace layers own member classification, lazy facades, buffering, and sequencing.
Still open are acknowledgements, flow control, sequence-gap recovery, reference collection if references are added, protocol-version negotiation, and how a future multi-pane presentation represents more than one selected Session. Singleton provider replacement must continue to install a complete replacement snapshot on the existing subscription so method and state member slots retain identity.
## Example: directory and selected session
@ -137,11 +139,9 @@ The directory and management services are normal server services. Their contract
```ts
interface SessionSummary {
serverId: string;
sessionId: string;
title: string;
workspaceId: string;
status: "starting" | "active" | "idle" | "closed" | "unreachable";
updatedAt: string;
createdAt: string;
}
interface SessionDirectory {
@ -149,6 +149,8 @@ interface SessionDirectory {
}
interface SessionManagement {
create(options: { id?: string }, context: Context): Promise<SessionSummary>;
remove(sessionId: string, context: Context): Promise<void>;
attach(sessionId: string, context: Context): Promise<void>;
detach(context: Context): Promise<void>;
}
@ -157,7 +159,7 @@ const SessionDirectory = defineService<SessionDirectory>("pi.session-directory")
const SessionManagement = defineService<SessionManagement>("pi.session-management");
```
A server facet supplies the services. The host-local attachment capability derives the client from `Context`, authorizes the requested session, and performs the binding transition:
A server facet derives the client from an authenticated `Context`, authorizes the requested Session, and performs the binding transition:
```ts
serverContext.provide(SessionDirectory, { state: directoryState });
@ -186,7 +188,7 @@ setup(env) {
if (snapshot === undefined) return;
const sessionId = await tui.select(
"Sessions",
snapshot.sessions.map((session) => ({ label: session.title, value: session.sessionId })),
snapshot.sessions.map((session) => ({ label: session.sessionId, value: session.sessionId })),
{ signal: operation.abortSignal },
);
if (sessionId !== undefined) await management.attach(sessionId, operation);

View file

@ -48,6 +48,7 @@ export {
UnknownTarget,
UnknownTemplate,
} from "./result.ts";
export { SliceNotImplemented } from "./runtime/types.ts";
import { createAgentHarness } from "./runtime/harness.ts";
import type {

View file

@ -20,7 +20,7 @@ export class ServiceSliceNotImplemented extends Error {
readonly code = "service_not_implemented" as const;
constructor(operation: string) {
super(`${operation} is not implemented until its later plugin-service slice`);
super(`${operation} is not implemented until its later facet-service slice`);
this.name = "ServiceSliceNotImplemented";
}
}

View file

@ -2,6 +2,7 @@ import {
type AgentLane,
type RunResult as HarnessRunResult,
ServiceSliceNotImplemented,
SliceNotImplemented,
} from "@earendil-works/pi-agent-core";
import type { PromptArguments } from "@earendil-works/pi-protocol";
import { defineFacet } from "../facets.ts";
@ -12,16 +13,20 @@ import { Lane } from "./harness.ts";
export function createChatService(lane: AgentLane): ChatService {
return {
async prompt(request, context) {
const prompt = toHarnessPromptArguments(toPromptArguments(request));
const result =
typeof prompt[0] === "string"
? await lane.prompt(prompt[0], prompt[1], context)
: await lane.prompt(prompt[0], context);
return toChatPromptResponse(result);
return mapHarnessSlice("Chat.prompt", async () => {
const prompt = toHarnessPromptArguments(toPromptArguments(request));
const result =
typeof prompt[0] === "string"
? await lane.prompt(prompt[0], prompt[1], context)
: await lane.prompt(prompt[0], context);
return toChatPromptResponse(result);
});
},
async requestAbort(operationId, context) {
const result = await lane.requestAbort(operationId, context);
if (!result.ok) throw new Error(result.error.message);
await mapHarnessSlice("Chat.requestAbort", async () => {
const result = await lane.requestAbort(operationId, context);
if (!result.ok) throw new Error(result.error.message);
});
},
async steer() {
throw new ServiceSliceNotImplemented("Chat.steer");
@ -47,6 +52,15 @@ export function createChatService(lane: AgentLane): ChatService {
};
}
async function mapHarnessSlice<T>(operation: string, callback: () => Promise<T>): Promise<T> {
try {
return await callback();
} catch (error) {
if (error instanceof SliceNotImplemented) throw new ServiceSliceNotImplemented(operation);
throw error;
}
}
export const chatServiceFacet = defineFacet({
id: "@pi/chat",
setup(env) {

View file

@ -6,12 +6,17 @@ import {
InvalidMessage,
LaneBusy,
ServiceSliceNotImplemented,
SliceNotImplemented,
UnknownSkill,
UnknownTemplate,
} from "@earendil-works/pi-agent-core";
import { describe, expect, test, vi } from "vitest";
import { createFacetHost, defineFacet } from "../src/experimental/facets.ts";
import { chatServiceFacet, toChatPromptResponse } from "../src/experimental/services/chat-provider.ts";
import {
chatServiceFacet,
createChatService,
toChatPromptResponse,
} from "../src/experimental/services/chat-provider.ts";
import { Lane } from "../src/experimental/services/harness.ts";
const admissionErrors = [
@ -88,6 +93,27 @@ describe("Chat service", () => {
}
});
test("maps missing Harness slices to service errors", async () => {
const lane = {
async prompt() {
throw new SliceNotImplemented("prompt");
},
async requestAbort() {
throw new SliceNotImplemented("requestAbort");
},
} as unknown as AgentLane;
const chat = createChatService(lane);
await expect(chat.prompt({ message: "hello", images: null }, BACKGROUND_CONTEXT)).rejects.toMatchObject({
name: "ServiceSliceNotImplemented",
code: "service_not_implemented",
});
await expect(chat.requestAbort("operation-1", BACKGROUND_CONTEXT)).rejects.toMatchObject({
name: "ServiceSliceNotImplemented",
code: "service_not_implemented",
});
});
test("reports accepted successful and failed operations", () => {
expect(
toChatPromptResponse({

View file

@ -489,7 +489,7 @@ describe("experimental durable server composition", () => {
await expect(transcript.snapshot(BACKGROUND_CONTEXT)).rejects.toMatchObject({
code: "service_not_implemented",
message: "Transcript.snapshot is not implemented until its later plugin-service slice",
message: "Transcript.snapshot is not implemented until its later facet-service slice",
});
await services.dispose(BACKGROUND_CONTEXT);
});

View file

@ -2,7 +2,7 @@
Runtime-neutral schemas, types, CBOR encoding, and byte-stream framing for the experimental Pi protocol.
Protocol version `3` carries the current server- and Session-scoped plugin-service slice:
Protocol version `3` carries the current server- and Session-scoped facet-service slice:
- a version handshake that identifies the logical `serverId`;
- explicit server and Session request targets;
@ -10,11 +10,11 @@ Protocol version `3` carries the current server- and Session-scoped plugin-servi
- singleton and keyed-service subscription snapshots plus ordered state, spawn, and close updates;
- correlated responses, request cancellation, out-of-band attachment updates, attachment-scoped events, and bounded protocol errors.
The transport carries `{ serviceId, instance?, member, args }` calls. A server target contains `{ serverId }`; a Session target contains `{ serverId, sessionId, attachmentId }`. Keyed addresses contain a plugin key and provider-owned generation so delayed calls cannot reach a replacement instance. Service subscriptions return a complete member/state snapshot before update delivery starts; member metadata may declare `events` members whose ordered non-durable values use service update frames and are not replayed to late subscribers. Session-directory state and management results contain no working directory, owner, storage, or parent metadata. The server derives private creation fields from authenticated workspace context. Management `attach()` and `detach()` return no routing identifiers; the server publishes the selected live route in an out-of-band `attachment` message. `prompt()` targets that exact route. The real `Session` and `AgentHarness` remain process-local. Disconnecting releases only that presentation's attachment after admitted work settles.
The transport carries `{ serviceId, instance?, member, args }` calls. A server target contains `{ serverId }`; a Session target contains `{ serverId, sessionId, attachmentId }`. Keyed addresses contain an application key and provider-owned generation so delayed calls cannot reach a replacement instance. Service subscriptions return a complete member/state snapshot before update delivery starts. Service updates carry state, keyed spawn/close, temporary unavailability, and complete singleton replacement; there is no remote service-event member kind. Session-directory state and management results contain no working directory, owner, storage, or parent metadata. Management `attach()` and `detach()` return no routing identifiers; the server publishes the selected live route in an out-of-band `attachment` message. `prompt()` targets that exact route. The real `Session` and `AgentHarness` remain process-local. Disconnecting releases only that presentation's attachment after admitted work settles.
`PromptArguments` contains one serializable `AgentLane.prompt()` overload. `PromptMessage` is the protocol's closed set of built-in message DTOs; application-defined `AgentMessage` extensions are not accepted implicitly. `RunResult` is the wire-safe structural equivalent of the Harness result and contains no JavaScript `Error` instances.
The temporary `ServiceRpc` manifest still backs low-level client compatibility methods and transcript operations while those features migrate to plugin services. Experimental presentations consume server-owned `SessionDirectory` and `SessionManagement` services plus the worker-owned built-in service inventory. `Chat` and `Models` have implemented providers; `Accounts` and revisioned `Transcript` are explicit later-slice scaffolds. Session plugin calls route opaquely to the attached worker, where the provider validates and invokes them.
The temporary `ServiceRpc` manifest still backs low-level client compatibility methods while those features migrate to services. Experimental presentations consume server-owned `SessionDirectory` and `SessionManagement` plus provider-generated Session service catalogues; there is no handwritten worker service inventory. `Models` has an implemented provider. `Chat` is wired to `AgentLane`; `Accounts` and revisioned `Transcript` retain placeholder providers while those features are completed. Session service calls route opaquely to the attached worker, where the provider validates and invokes them.
A lane watch uses three RPCs so snapshot ordering does not depend on response/event scheduling: `watch()` creates a buffering watch and returns its authoritative snapshot, `startWatch()` flushes buffered events and begins live delivery, and `stopWatch()` releases it. Events carry their watch ID. Streaming message updates carry compact assistant-message frames rather than cumulative partial messages. Reconnection creates a new watch and snapshot rather than replaying old events.
@ -38,6 +38,6 @@ for (const message of decoder.push(incomingChunk)) handleServerMessage(message);
decoder.end();
```
All schemas reject unknown object properties. Schema violations, malformed CBOR, and invalid framing throw `ProtocolValidationError`. Transports authenticate peers before passing protocol bytes and must preserve byte order.
All schemas reject unknown object properties. Schema violations, malformed CBOR, and invalid framing throw `ProtocolValidationError`. Transports must preserve byte order. Peer authentication and authenticated service contexts are not implemented by the experimental transport.
Default limits are 16 MiB per CBOR payload/frame, 1,000,000 array elements or map entries, and 64 nested item levels. The protocol is experimental and has no compatibility guarantees.

View file

@ -2,7 +2,7 @@
Experimental local server for the new durable Session and Agent Harness interfaces.
The current slice supports server- and Session-scoped plugin-service routing, multi-presentation attachment, prompting, and optional main-lane observation. `RoutedServerServiceHost.attachClient()` creates one connection-scoped server service endpoint with narrow attachment-management capabilities. `RoutedSessionHandle.attachClient()` returns a presentation-scoped Session capability. Its optional `invokeService()` forwards an opaque service/member envelope to the selected Session endpoint; the server validates the attachment route but does not load the plugin contract. Its optional `watch()` supplies an authoritative snapshot plus buffered events.
The current slice supports server- and Session-scoped facet-service routing, multi-presentation attachment, prompting, and optional main-lane observation. `RoutedServerServiceHost.attachClient()` creates one connection-scoped server service endpoint with narrow attachment-management capabilities. `RoutedSessionHandle.attachClient()` returns a presentation-scoped Session capability. Its optional `invokeService()` forwards an opaque service/member envelope to the selected Session endpoint; the server validates the attachment route but does not load the facet contract. Its optional `watch()` supplies an authoritative snapshot plus buffered events.
- server service calls and subscriptions route opaquely through the connection's `RoutedServerServiceAttachment`;
- the application-owned `SessionDirectory` projects the private catalog into replicated presentation-safe state;
@ -73,6 +73,6 @@ Applications supply a private Session catalog and a routed Session factory. The
`serverId` is a logical identity supplied by the launcher, not a socket address. The Unix preset requires an explicit physical `path`; `getUnixSocketPath()` derives one from a caller-selected directory. Choose a short, private runtime directory rather than deriving the route from an unbounded home-directory path. A long-lived launcher can reuse the same ID and path when replacing a server process.
`Server` composes authenticated transports through `ServerListener`. The Unix submodule provides `createUnixListener()` and `createUnixServer()`. Low-level CBOR framing and validation come from `@earendil-works/pi-protocol`.
`Server` composes transports through `ServerListener`; peer authentication remains application policy and is not implemented by the experimental Unix transport. The Unix submodule provides `createUnixListener()` and `createUnixServer()`. Low-level CBOR framing and validation come from `@earendil-works/pi-protocol`.
Server and worker lifecycle is managed outside the public Pi protocol. The replaceable application server converts connection attachments into private demand updates; the worker combines generation-tagged demand with authoritative Harness activity. The experimental coordinator only supplies stable routing and reports generic server-generation connection changes.